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

nordisk/monetize

crates/mock-upcloud/src/documented.rs

//! **The API's DOCUMENTED refusals** (behaviour 74, bughunt 2026-10-14).
//!
//! The first live site order failed on a rule the mock never asked (the template clone's
//! tier, `409 STORAGE_TIER_ILLEGAL`): everything passed here and the account refused. This
//! module is the rest of that class — each check below is a rule the UpCloud API 1.3
//! reference (developers.upcloud.com/1.3, read 2026-10-14) writes down WITH its error code,
//! which the mock let through. Only documented refusals: a rule the docs do not state (a
//! `user_data` size, a firewall comment's charset) is not invented here — a mock stricter
//! than the provider is worse than a laxer one (see the crate doc).
//!
//! | rule | code | where in the docs |
//! |---|---|---|
//! | a label key is 2–32 printable ASCII (0x20–0x7E), never starting `_`; a value 0–255 chars | `400 INVALID_LABEL_KEY` / `400 INVALID_LABEL_VALUE` | 8 servers, 9 storages: labels |
//! | a storage title is 1–255 chars (create, clone, templatize) | `400 TITLE_INVALID` / `TITLE_MISSING` | 9 storages |
//! | a storage tier is `hdd`, `standard` or `maxiops` | `400 TIER_INVALID` | 9 storages: create, clone |
//! | a storage size is 1–4096 GB | `400 SIZE_INVALID` | 9 storages: create |
//! | a clone names its zone | `400 ZONE_MISSING` | 9 storages: clone (`zone` required) |
//! | only a `maxiops` storage is templatized | `409 STORAGE_TIER_ILLEGAL` | 9 storages: templatize |
//! | a server names its zone, a title (0–255) and a hostname: a lowercase domain name, ≤ 128 | `400 ZONE_MISSING` / `SERVER_TITLE_INVALID` / `HOSTNAME_MISSING` / `HOSTNAME_INVALID` | 8 servers: create |
//! | `simple_backup` is `HHMM,{daily,dailies,weeklies,monthlies}` or `no` | `400 SIMPLE_BACKUP_INVALID` | 8 servers: create |
//! | a storage attached at create is in the server's zone | `409 ZONE_MISMATCH` | 8 servers: create |
//! | a firewall rule: `direction` in/out, `action` accept/drop, `protocol` tcp/udp/icmp, ports 1–65535 in start/end pairs only with tcp/udp, `icmp_type` only with icmp, address pairs, end ≥ start, comment ≤ 250; ≤ 1000 rules | the codes in [`firewall_refusal`] | 11 firewall |
//! | a STARTER plan's included storage is `standard` | (the plan list's `storage_tier`) | products: cloud server configurations |

use serde_json::Value;

use crate::estate::{Label, Refusal};

/// The storage tiers the API names (`hdd` is Archive).
pub const TIERS: [&str; 3] = ["hdd", "standard", "maxiops"];

/// A label set the API refuses, or `None`.
pub fn label_refusal(labels: &[Label]) -> Option<Refusal> {
    for l in labels {
        let k = l.key.as_str();
        let printable = k.bytes().all(|b| (0x20..=0x7e).contains(&b));
        if !(2..=32).contains(&k.len()) || !printable || k.starts_with('_') {
            return Some(Refusal::new(400, "INVALID_LABEL_KEY", format!("label key {k:?}: 2-32 printable ASCII characters, not starting with `_`")));
        }
        if l.value.chars().count() > 255 {
            return Some(Refusal::new(400, "INVALID_LABEL_VALUE", format!("label {k:?}: a value is 0-255 characters")));
        }
    }
    None
}

/// A storage `title` (required, 1–255) the API refuses, or `None`.
pub fn storage_title_refusal(title: Option<&str>) -> Option<Refusal> {
    match title {
        None => Some(Refusal::new(400, "TITLE_INVALID", "The required attribute title is missing from the request.")),
        Some(t) if t.is_empty() || t.chars().count() > 255 => Some(Refusal::new(400, "TITLE_INVALID", "The attribute title has an invalid value (1-255 characters).")),
        Some(_) => None,
    }
}

/// A `tier` the API refuses (absent is the default, accepted), or `None`.
pub fn tier_refusal(tier: Option<&str>) -> Option<Refusal> {
    tier.filter(|t| !TIERS.contains(t)).map(|t| Refusal::new(400, "TIER_INVALID", format!("The attribute tier has an invalid value: {t:?} (hdd, standard, maxiops).")))
}

/// A create's `size` the API refuses (1–4096 GB), or `None`.
pub fn size_refusal(size_gib: u64) -> Option<Refusal> {
    (!(1..=4096).contains(&size_gib)).then(|| Refusal::new(400, "SIZE_INVALID", format!("The attribute size has an invalid value: {size_gib} (1-4096 GB).")))
}

/// A missing or empty `zone` (required on a server create and on a clone).
pub fn zone_refusal(zone: Option<&str>) -> Option<Refusal> {
    zone.is_none_or(|z| z.trim().is_empty()).then(|| Refusal::new(400, "ZONE_MISSING", "The required attribute zone is missing from the request."))
}

/// `hostname`: "A valid domain name … Maximum 128 characters … must be in lower case."
pub fn hostname_refusal(hostname: Option<&str>) -> Option<Refusal> {
    let Some(h) = hostname else {
        return Some(Refusal::new(400, "HOSTNAME_MISSING", "The required attribute hostname is missing from the request."));
    };
    let label_ok = |l: &str| {
        (1..=63).contains(&l.len()) && !l.starts_with('-') && !l.ends_with('-') && l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
    };
    let ok = !h.is_empty() && h.len() <= 128 && h.split('.').all(label_ok);
    (!ok).then(|| Refusal::new(400, "HOSTNAME_INVALID", format!("The attribute hostname has an invalid value. Value is case sensitive and must be in lower case. ({h:?})")))
}

/// A server `title` over 255 characters.
pub fn server_title_refusal(title: Option<&str>) -> Option<Refusal> {
    match title {
        None => Some(Refusal::new(400, "SERVER_TITLE_MISSING", "The required attribute title is missing from the server block.")),
        Some(t) if t.chars().count() > 255 => Some(Refusal::new(400, "SERVER_TITLE_INVALID", "The attribute title in the server block has an invalid value.")),
        Some(_) => None,
    }
}

/// `simple_backup`: `HHMM,{daily|dailies|weeklies|monthlies}` or `no` (absent: the account's default).
pub fn simple_backup_refusal(sb: Option<&str>) -> Option<Refusal> {
    let sb = sb?;
    if sb == "no" {
        return None;
    }
    let ok = sb.split_once(',').is_some_and(|(t, kind)| {
        t.len() == 4 && t.bytes().all(|b| b.is_ascii_digit()) && t[..2].parse::<u8>().is_ok_and(|h| h < 24) && t[2..].parse::<u8>().is_ok_and(|m| m < 60) && matches!(kind, "daily" | "dailies" | "weeklies" | "monthlies")
    });
    (!ok).then(|| Refusal::new(400, "SIMPLE_BACKUP_INVALID", format!("The attribute simple_backup has an invalid value: {sb:?}.")))
}

/// The most rules one server's firewall holds.
pub const FIREWALL_MAX_RULES: usize = 1000;

/// **A firewall write the API refuses** (`PUT`/`POST /server/{uuid}/firewall_rule`, read off the
/// RAW body: the mock's own parser fills a missing `direction`/`action` with a default the
/// API never assumes).
pub fn firewall_refusal(body: &Value, existing: usize) -> Option<Refusal> {
    let rules: Vec<&Value> = match body["firewall_rules"]["firewall_rule"].as_array() {
        Some(a) => a.iter().collect(),
        None if body["firewall_rule"].is_object() => vec![&body["firewall_rule"]],
        None => vec![],
    };
    if existing + rules.len() > FIREWALL_MAX_RULES {
        return Some(Refusal::new(409, "FIREWALL_RULE_LIMIT_REACHED", format!("The maximum number of firewall rules per server is {FIREWALL_MAX_RULES}.")));
    }
    rules.into_iter().enumerate().find_map(|(i, r)| one_rule_refusal(r).map(|x| Refusal::new(x.status, x.code, format!("rule {}: {}", i + 1, x.message))))
}

fn text(v: &Value, k: &str) -> Option<String> {
    match &v[k] {
        Value::String(s) if !s.is_empty() => Some(s.clone()),
        Value::Number(n) => Some(n.to_string()),
        _ => None,
    }
}

fn one_rule_refusal(r: &Value) -> Option<Refusal> {
    let bad = |code: &'static str, what: String| Some(Refusal::new(400, code, what));
    match text(r, "direction").as_deref() {
        None => return bad("DIRECTION_MISSING", "The required attribute direction is missing.".into()),
        Some("in" | "out") => {}
        Some(d) => return bad("DIRECTION_INVALID", format!("direction {d:?} (in, out)")),
    }
    match text(r, "action").as_deref() {
        None => return bad("ACTION_MISSING", "The required attribute action is missing.".into()),
        Some("accept" | "drop") => {}
        Some(a) => return bad("ACTION_INVALID", format!("action {a:?} (accept, drop)")),
    }
    let protocol = text(r, "protocol");
    if let Some(p) = protocol.as_deref() {
        if !matches!(p, "tcp" | "udp" | "icmp") {
            return bad("PROTOCOL_INVALID", format!("protocol {p:?} (tcp, udp, icmp)"));
        }
    }
    if let Some(c) = text(r, "comment") {
        if c.chars().count() > 250 {
            return bad("COMMENT_INVALID", format!("a comment is 0-250 characters ({})", c.chars().count()));
        }
    }
    // Ports: 1-65535, start and end together, end ≥ start, only for tcp/udp.
    for (which, start_bad, end_bad, order) in [
        ("destination_port", "DESTINATION_PORT_START_INVALID", "DESTINATION_PORT_END_INVALID", "DESTINATION_PORT_ORDER_ILLEGAL"),
        ("source_port", "SOURCE_PORT_START_INVALID", "SOURCE_PORT_END_INVALID", "SOURCE_PORT_ORDER_ILLEGAL"),
    ] {
        let (s, e) = (text(r, &format!("{which}_start")), text(r, &format!("{which}_end")));
        if s.is_none() && e.is_none() {
            continue;
        }
        if !matches!(protocol.as_deref(), Some("tcp" | "udp")) {
            return bad("PORT_PROTOCOL_MISMATCH", format!("{which} set with protocol {:?}: ports are for tcp and udp only", protocol.as_deref().unwrap_or("")));
        }
        let port = |v: &Option<String>| v.as_deref().and_then(|x| x.parse::<u32>().ok()).filter(|p| (1..=65535).contains(p));
        let Some(a) = port(&s) else { return bad(start_bad, format!("{which}_start {s:?}")) };
        let Some(b) = port(&e) else { return bad(end_bad, format!("{which}_end {e:?}")) };
        if b < a {
            return bad(order, format!("{which}: end {b} < start {a}"));
        }
    }
    if text(r, "icmp_type").is_some() && protocol.as_deref() != Some("icmp") {
        return bad("ICMP_TYPE_PROTOCOL_MISMATCH", "icmp_type is set but the protocol is not icmp".into());
    }
    for (which, start_bad, end_bad, order) in [
        ("destination_address", "DESTINATION_ADDRESS_START_INVALID", "DESTINATION_ADDRESS_END_INVALID", "DESTINATION_ADDRESS_ORDER_ILLEGAL"),
        ("source_address", "SOURCE_ADDRESS_START_INVALID", "SOURCE_ADDRESS_END_INVALID", "SOURCE_ADDRESS_ORDER_ILLEGAL"),
    ] {
        let (s, e) = (text(r, &format!("{which}_start")), text(r, &format!("{which}_end")));
        if s.is_none() && e.is_none() {
            continue;
        }
        let ip = |v: &Option<String>| v.as_deref().and_then(|x| x.parse::<std::net::IpAddr>().ok());
        let Some(a) = ip(&s) else { return bad(start_bad, format!("{which}_start {s:?}")) };
        let Some(b) = ip(&e) else { return bad(end_bad, format!("{which}_end {e:?}")) };
        if b < a {
            return bad(order, format!("{which}: end {b} < start {a}"));
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn l(k: &str, v: &str) -> Label {
        Label { key: k.into(), value: v.into() }
    }

    #[test]
    fn labels_are_refused_as_the_docs_say() {
        assert!(label_refusal(&[l("monetize_ref", "estate/acme"), l("role", "front")]).is_none());
        assert_eq!(label_refusal(&[l("r", "x")]).unwrap().code, "INVALID_LABEL_KEY");
        assert_eq!(label_refusal(&[l("_service", "x")]).unwrap().code, "INVALID_LABEL_KEY");
        assert_eq!(label_refusal(&[l(&"k".repeat(33), "x")]).unwrap().code, "INVALID_LABEL_KEY");
        assert_eq!(label_refusal(&[l("käy", "x")]).unwrap().code, "INVALID_LABEL_KEY");
        assert_eq!(label_refusal(&[l("ok", &"v".repeat(256))]).unwrap().code, "INVALID_LABEL_VALUE");
    }

    #[test]
    fn storage_fields() {
        assert_eq!(storage_title_refusal(None).unwrap().code, "TITLE_INVALID");
        assert_eq!(storage_title_refusal(Some("")).unwrap().code, "TITLE_INVALID");
        assert!(storage_title_refusal(Some("gunnar-front-acme system")).is_none());
        assert_eq!(tier_refusal(Some("archive")).unwrap().code, "TIER_INVALID");
        assert!(tier_refusal(Some("standard")).is_none() && tier_refusal(None).is_none());
        assert_eq!(size_refusal(0).unwrap().code, "SIZE_INVALID");
        assert_eq!(size_refusal(4097).unwrap().code, "SIZE_INVALID");
        assert!(size_refusal(4096).is_none());
        assert_eq!(zone_refusal(None).unwrap().code, "ZONE_MISSING");
        assert!(zone_refusal(Some("se-sto1")).is_none());
    }

    #[test]
    fn hostnames_are_lowercase_domain_names() {
        for ok in ["gunnar-front-acme", "test2.gunnar.rs", "a"] {
            assert!(hostname_refusal(Some(ok)).is_none(), "{ok}");
        }
        assert_eq!(hostname_refusal(None).unwrap().code, "HOSTNAME_MISSING");
        for bad in ["Gunnar-Front", "gunnar front", "gunnar_front", "-x", "x..y", &"a".repeat(129)] {
            assert_eq!(hostname_refusal(Some(bad)).unwrap().code, "HOSTNAME_INVALID", "{bad}");
        }
        assert_eq!(server_title_refusal(Some(&"t".repeat(256))).unwrap().code, "SERVER_TITLE_INVALID");
    }

    #[test]
    fn simple_backup_format() {
        for ok in ["0300,dailies", "2359,monthlies", "no"] {
            assert!(simple_backup_refusal(Some(ok)).is_none(), "{ok}");
        }
        assert!(simple_backup_refusal(None).is_none());
        for bad in ["03:00,dailies", "0300", "2400,daily", "0300,hourly", "yes"] {
            assert_eq!(simple_backup_refusal(Some(bad)).unwrap().code, "SIMPLE_BACKUP_INVALID", "{bad}");
        }
    }

    #[test]
    fn firewall_rules_are_checked_like_the_api() {
        let put = |rules: Value| json!({"firewall_rules": {"firewall_rule": rules}});
        let ok = put(json!([
            {"direction": "in", "action": "accept", "family": "IPv4", "protocol": "tcp", "destination_port_start": "443", "destination_port_end": "443", "source_address_start": "0.0.0.0", "source_address_end": "255.255.255.255", "comment": "HTTPS — front"},
            {"direction": "in", "action": "accept", "family": "IPv4", "protocol": "udp", "source_port_start": "53", "source_port_end": "53", "source_address_start": "94.237.127.9", "source_address_end": "94.237.127.9"},
            {"direction": "in", "action": "accept", "family": "IPv4", "protocol": "icmp"},
            {"direction": "in", "action": "drop"}
        ]));
        assert!(firewall_refusal(&ok, 0).is_none());
        let code = |r: Value| firewall_refusal(&put(json!([r])), 0).map(|x| x.code);
        assert_eq!(code(json!({"action": "drop"})), Some("DIRECTION_MISSING"));
        assert_eq!(code(json!({"direction": "in"})), Some("ACTION_MISSING"));
        assert_eq!(code(json!({"direction": "in", "action": "reject"})), Some("ACTION_INVALID"));
        assert_eq!(code(json!({"direction": "in", "action": "accept", "protocol": "sctp"})), Some("PROTOCOL_INVALID"));
        assert_eq!(code(json!({"direction": "in", "action": "accept", "destination_port_start": "22", "destination_port_end": "22"})), Some("PORT_PROTOCOL_MISMATCH"));
        assert_eq!(code(json!({"direction": "in", "action": "accept", "protocol": "icmp", "destination_port_start": "22", "destination_port_end": "22"})), Some("PORT_PROTOCOL_MISMATCH"));
        assert_eq!(code(json!({"direction": "in", "action": "accept", "protocol": "tcp", "destination_port_start": "22"})), Some("DESTINATION_PORT_END_INVALID"));
        assert_eq!(code(json!({"direction": "in", "action": "accept", "protocol": "tcp", "destination_port_start": "0", "destination_port_end": "22"})), Some("DESTINATION_PORT_START_INVALID"));
        assert_eq!(code(json!({"direction": "in", "action": "accept", "protocol": "tcp", "destination_port_start": "30", "destination_port_end": "22"})), Some("DESTINATION_PORT_ORDER_ILLEGAL"));
        assert_eq!(code(json!({"direction": "in", "action": "accept", "protocol": "tcp", "icmp_type": "8"})), Some("ICMP_TYPE_PROTOCOL_MISMATCH"));
        assert_eq!(code(json!({"direction": "in", "action": "accept", "family": "IPv4", "source_address_start": "10.0.0.9"})), Some("SOURCE_ADDRESS_END_INVALID"));
        assert_eq!(code(json!({"direction": "in", "action": "accept", "family": "IPv4", "source_address_start": "10.0.0.9", "source_address_end": "10.0.0.1"})), Some("SOURCE_ADDRESS_ORDER_ILLEGAL"));
        assert_eq!(code(json!({"direction": "in", "action": "accept", "comment": "c".repeat(251)})), Some("COMMENT_INVALID"));
        let many: Vec<Value> = (0..1001).map(|_| json!({"direction": "in", "action": "drop"})).collect();
        assert_eq!(firewall_refusal(&put(json!(many)), 0).unwrap().code, "FIREWALL_RULE_LIMIT_REACHED");
        assert_eq!(firewall_refusal(&json!({"firewall_rule": {"direction": "in", "action": "drop"}}), 1000).unwrap().code, "FIREWALL_RULE_LIMIT_REACHED");
    }
}