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

nordisk/monetize

crates/mock-scaleway/tests/behaviours.rs

//! **The behaviours, over a real socket.** Each test drives the mock as the Scaleway guest's
//! host door does — HTTP/1.1, `X-Auth-Token`, JSON — and asserts what a client would see.
//! The numbers match the table in the crate header.

use mock_scaleway::{call, Fault, Mock, Reply};
use serde_json::{json, Value};

const Z: &str = "/instance/v1/zones/fr-par-1";

struct Rig {
    mock: std::sync::Arc<Mock>,
    base: String,
    key: String,
    project: String,
}

impl Rig {
    fn new() -> Rig {
        let mock = Mock::start();
        Rig { base: mock.base_url(), key: mock.token(), project: mock.project(), mock }
    }
    fn req(&self, method: &str, path: &str, body: Option<Value>) -> Reply {
        let b = body.map(|v| serde_json::to_vec(&v).unwrap()).unwrap_or_default();
        call(&self.base, method, &format!("{Z}{path}"), Some(&self.key), (!b.is_empty()).then_some("application/json"), &b).expect("the mock answers")
    }
    fn get(&self, path: &str) -> Value {
        let r = self.req("GET", path, None);
        assert_eq!(r.status, 200, "GET {path}: {}", r.text());
        r.json()
    }
    fn server(&self, name: &str, tags: &[&str]) -> Value {
        let r = self.req("POST", "/servers", Some(json!({ "name": name, "commercial_type": "DEV1-S", "project": self.project, "image": "ubuntu_noble", "tags": tags })));
        assert_eq!(r.status, 201, "{}", r.text());
        r.json()["server"].clone()
    }
    fn state_of(&self, id: &str) -> String {
        self.get(&format!("/servers/{id}"))["server"]["state"].as_str().unwrap().to_string()
    }
    /// Poll until `id` reads `want`, at most `n` polls (each moves the virtual clock 5 s).
    fn until(&self, id: &str, want: &str, n: usize) -> Vec<String> {
        let mut seen = vec![];
        for _ in 0..n {
            let s = self.state_of(id);
            seen.push(s.clone());
            if s == want {
                return seen;
            }
        }
        panic!("{id} never read {want}: {seen:?}");
    }
    fn action(&self, id: &str, action: &str) -> Reply {
        self.req("POST", &format!("/servers/{id}/action"), Some(json!({ "action": action })))
    }
}

#[test]
fn b1_a_server_is_created_stopped_and_poweron_runs_it_through_starting() {
    let r = Rig::new();
    let s = r.server("acme-main", &["monetize_ref=t/acme"]);
    assert_eq!(s["state"], "stopped");
    assert_eq!(s["commercial_type"], "DEV1-S");
    assert!(s["volumes"]["0"]["id"].is_string() && s["volumes"]["0"]["size"].as_u64().unwrap() > 0, "{s}");
    assert_eq!(s["security_group"]["name"], "Default security group");
    let id = s["id"].as_str().unwrap();
    let a = r.action(id, "poweron");
    assert_eq!(a.status, 202, "{}", a.text());
    assert_eq!(a.json()["task"]["status"], "pending");
    let seen = r.until(id, "running", 10);
    assert_eq!(seen[0], "starting", "the first read is the transition: {seen:?}");
    assert!(seen.len() > 2, "poweron takes polls, not one: {seen:?}");
    let task = a.json()["task"]["id"].as_str().unwrap().to_string();
    assert_eq!(r.get(&format!("/tasks/{task}"))["task"]["status"], "success");
}

#[test]
fn b2_an_action_in_transition_is_a_409_transient_state() {
    let r = Rig::new();
    let id = r.server("a", &[])["id"].as_str().unwrap().to_string();
    assert_eq!(r.action(&id, "poweron").status, 202);
    let again = r.action(&id, "poweroff");
    assert_eq!(again.status, 409, "{}", again.text());
    assert_eq!(again.json()["type"], "transient_state");
}

#[test]
fn b3_out_of_stock_on_poweron_is_412_and_sticky_until_disarmed() {
    let r = Rig::new();
    let id = r.server("a", &[])["id"].as_str().unwrap().to_string();
    r.mock.arm(Fault::OutOfStock(None));
    for _ in 0..3 {
        let a = r.action(&id, "poweron");
        assert_eq!((a.status, a.json()["type"].as_str()), (412, Some("out_of_stock")), "{}", a.text());
    }
    assert_eq!(r.get("/products/servers/availability")["servers"]["DEV1-S"]["availability"], "shortage");
    assert_eq!(r.state_of(&id), "stopped", "nothing started");
    r.mock.disarm(&Fault::OutOfStock(None));
    assert_eq!(r.action(&id, "poweron").status, 202);
    // Only one type out of stock.
    r.mock.arm(Fault::OutOfStock(Some("PRO2-S".into())));
    assert_eq!(r.get("/products/servers/availability")["servers"]["DEV1-S"]["availability"], "available");
}

#[test]
fn b4_a_resize_needs_a_stopped_server() {
    let r = Rig::new();
    let id = r.server("a", &[])["id"].as_str().unwrap().to_string();
    assert_eq!(r.action(&id, "poweron").status, 202);
    r.until(&id, "running", 10);
    let p = r.req("PATCH", &format!("/servers/{id}"), Some(json!({ "commercial_type": "DEV1-M" })));
    assert_eq!((p.status, p.json()["type"].as_str()), (400, Some("invalid_request_error")), "{}", p.text());
    assert_eq!(r.action(&id, "poweroff").status, 202);
    r.until(&id, "stopped", 10);
    let p = r.req("PATCH", &format!("/servers/{id}"), Some(json!({ "commercial_type": "DEV1-M" })));
    assert_eq!(p.status, 200, "{}", p.text());
    assert_eq!(p.json()["server"]["commercial_type"], "DEV1-M");
    let bad = r.req("PATCH", &format!("/servers/{id}"), Some(json!({ "commercial_type": "NOPE-1" })));
    assert_eq!(bad.json()["type"], "invalid_arguments");
}

#[test]
fn b5_a_revoked_key_lists_no_servers_and_is_403_elsewhere_never_401() {
    let r = Rig::new();
    r.server("a", &[]);
    assert_eq!(r.get("/servers")["servers"].as_array().unwrap().len(), 1);
    r.mock.revoke_token();
    let l = r.req("GET", "/servers", None);
    assert_eq!(l.status, 200);
    assert_eq!(l.json()["servers"], json!([]), "a revoked key sees an EMPTY account");
    for (m, p) in [("GET", "/products/servers"), ("GET", "/volumes"), ("POST", "/servers")] {
        let a = r.req(m, p, (m == "POST").then(|| json!({})));
        assert_eq!((a.status, a.json()["type"].as_str()), (403, Some("permissions_denied")), "{m} {p}: {}", a.text());
    }
    // An unknown key and no key are a clean 401.
    let a = call(&r.base, "GET", &format!("{Z}/servers"), Some("scw-wrong"), None, b"").unwrap();
    assert_eq!((a.status, a.json()["type"].as_str()), (401, Some("denied_authentication")));
    assert_eq!(call(&r.base, "GET", &format!("{Z}/servers"), None, None, b"").unwrap().status, 401);
}

#[test]
fn b6_terminate_takes_the_volumes_and_the_dynamic_ip_and_keeps_the_backups() {
    let r = Rig::new();
    let s = r.server("acme-main", &["monetize_ref=t/acme"]);
    let id = s["id"].as_str().unwrap().to_string();
    let root = s["volumes"]["0"]["id"].as_str().unwrap().to_string();
    assert!(s["public_ip"]["dynamic"].as_bool().unwrap(), "a dynamic ip by default: {s}");
    let flex = r.req("POST", "/ips", Some(json!({ "project": r.project, "server": id, "tags": ["monetize_ref=t/acme"] })));
    assert_eq!(flex.status, 201, "{}", flex.text());
    let flex = flex.json()["ip"]["id"].as_str().unwrap().to_string();
    // A backup: the snapshot of the root volume, and a whole-server backup (image + snapshot).
    let snap = r.req("POST", "/snapshots", Some(json!({ "name": "nightly", "volume_id": root, "project": r.project, "tags": ["monetize_ref=t/acme"] })));
    assert_eq!(snap.status, 201, "{}", snap.text());
    assert_eq!(snap.json()["snapshot"]["base_volume"]["id"], root.as_str());
    assert_eq!(r.action(&id, "poweron").status, 202);
    r.until(&id, "running", 10);
    assert_eq!(r.action(&id, "backup").status, 202);
    assert_eq!(r.mock.snapshots().len(), 2);
    assert_eq!(r.mock.images().len(), 1);
    assert_eq!(r.action(&id, "terminate").status, 202);
    for _ in 0..10 {
        if r.req("GET", &format!("/servers/{id}"), None).status == 404 {
            break;
        }
    }
    let gone = r.req("GET", &format!("/servers/{id}"), None);
    assert_eq!((gone.status, gone.json()["type"].as_str()), (404, Some("unknown_resource")), "{}", gone.text());
    assert_eq!(r.req("GET", &format!("/volumes/{root}"), None).status, 404, "terminate deletes the volumes");
    assert!(r.mock.ips().iter().all(|i| i["id"] == flex.as_str()), "the dynamic ip went, the flexible stays: {:?}", r.mock.ips());
    assert_eq!(r.get(&format!("/ips/{flex}"))["ip"]["server"], Value::Null);
    // THE TRAP: the backups are still there, billed. A give-back deletes them itself.
    assert_eq!(r.mock.snapshots().len(), 2, "terminate keeps the snapshots");
    let image = r.mock.images()[0]["id"].as_str().unwrap().to_string();
    let image_snap = r.mock.images()[0]["root_volume"]["id"].as_str().unwrap().to_string();
    let refused = r.req("DELETE", &format!("/snapshots/{image_snap}"), None);
    assert_eq!((refused.status, refused.json()["precondition"].as_str()), (412, Some("resource_still_in_use")), "an image's snapshot: {}", refused.text());
    assert_eq!(r.req("DELETE", &format!("/images/{image}"), None).status, 204);
    assert_eq!(r.mock.snapshots().len(), 2, "deleting the image keeps its snapshots");
    for s in r.mock.snapshots() {
        assert_eq!(r.req("DELETE", &format!("/snapshots/{}", s["id"].as_str().unwrap()), None).status, 204);
    }
    assert_eq!(r.req("DELETE", &format!("/ips/{flex}"), None).status, 204);
    assert!(r.mock.snapshots().is_empty() && r.mock.ips().is_empty() && r.mock.volumes().is_empty() && r.mock.servers().is_empty());
}

#[test]
fn b7_delete_needs_stopped_and_leaves_the_volumes_detached() {
    let r = Rig::new();
    let s = r.server("a", &[]);
    let id = s["id"].as_str().unwrap().to_string();
    let root = s["volumes"]["0"]["id"].as_str().unwrap().to_string();
    assert_eq!(r.action(&id, "poweron").status, 202);
    r.until(&id, "running", 10);
    assert_eq!(r.req("DELETE", &format!("/servers/{id}"), None).status, 400);
    assert_eq!(r.action(&id, "poweroff").status, 202);
    r.until(&id, "stopped", 10);
    let busy = r.req("DELETE", &format!("/volumes/{root}"), None);
    assert_eq!(busy.status, 412, "an attached volume: {}", busy.text());
    assert_eq!(r.req("DELETE", &format!("/servers/{id}"), None).status, 204);
    let v = r.get(&format!("/volumes/{root}"));
    assert_eq!((v["volume"]["server"].clone(), v["volume"]["state"].as_str()), (Value::Null, Some("available")), "left behind, detached");
    assert_eq!(r.req("DELETE", &format!("/volumes/{root}"), None).status, 204);
}

#[test]
fn b9_10_tags_find_exactly_the_servers_with_all_of_them_newest_first_and_paged() {
    let r = Rig::new();
    let a = r.server("a", &["monetize_ref=t/acme", "monetize_res=main"])["id"].as_str().unwrap().to_string();
    let b = r.server("b", &["monetize_ref=t/acme", "monetize_res=twin"])["id"].as_str().unwrap().to_string();
    r.server("c", &["monetize_ref=t/other"]);
    let found = |q: &str| -> Vec<String> { r.get(&format!("/servers?{q}"))["servers"].as_array().unwrap().iter().map(|s| s["id"].as_str().unwrap().to_string()).collect() };
    assert_eq!(found("tags=monetize_ref%3Dt%2Facme,monetize_res%3Dmain"), vec![a.clone()], "ALL the tags, url-encoded");
    assert_eq!(found("tags=monetize_ref=t/acme"), vec![b.clone(), a.clone()], "newest first");
    assert_eq!(found(&format!("project={}", r.project)).len(), 3);
    assert!(found("project=someone-else").is_empty());
    // Idempotency by search: the lay that searches first finds its server, and creates none.
    assert_eq!(found("tags=monetize_ref=t/acme,monetize_res=main").len(), 1);
    for i in 0..60 {
        r.server(&format!("n{i}"), &["bulk"]);
    }
    let p1 = r.req("GET", "/servers?tags=bulk", None);
    assert_eq!(p1.json()["servers"].as_array().unwrap().len(), 50, "50 by default");
    assert_eq!(p1.header("x-total-count"), Some("60"));
    assert_eq!(r.get("/servers?tags=bulk&per_page=100&page=1")["servers"].as_array().unwrap().len(), 60);
    assert_eq!(r.get("/servers?tags=bulk&per_page=40&page=2")["servers"].as_array().unwrap().len(), 20);
    assert_eq!(r.req("GET", "/servers?per_page=101", None).status, 400);
}

#[test]
fn b9_security_group_rules_are_a_set_put_whole_and_a_used_group_stays() {
    let r = Rig::new();
    let g = r.req("POST", "/security_groups", Some(json!({ "name": "acme", "project": r.project, "stateful": true, "inbound_default_policy": "drop", "outbound_default_policy": "accept", "tags": ["monetize_ref=t/acme"] })));
    assert_eq!(g.status, 201, "{}", g.text());
    let gid = g.json()["security_group"]["id"].as_str().unwrap().to_string();
    let rules = json!({ "rules": [
        { "action": "accept", "protocol": "UDP", "direction": "inbound", "ip_range": "0.0.0.0/0", "dest_port_from": 51820, "dest_port_to": 51821, "position": 1, "editable": true },
        { "action": "accept", "protocol": "TCP", "direction": "inbound", "ip_range": "81.27.107.63/32", "dest_port_from": 8474, "dest_port_to": null, "position": 2, "editable": true },
    ]});
    let put = r.req("PUT", &format!("/security_groups/{gid}/rules"), Some(rules));
    assert_eq!(put.status, 200, "{}", put.text());
    assert_eq!(put.json()["rules"].as_array().unwrap().len(), 2);
    let got = r.get(&format!("/security_groups/{gid}/rules"));
    assert_eq!(got["rules"][0]["dest_port_from"], 51820);
    assert_eq!(got["rules"][1]["ip_range"], "81.27.107.63/32");
    // PUT replaces the set.
    let one = r.req("PUT", &format!("/security_groups/{gid}/rules"), Some(json!({ "rules": [{ "action": "accept", "protocol": "TCP", "direction": "inbound", "ip_range": "0.0.0.0/0", "dest_port_from": 443, "position": 1 }] })));
    assert_eq!(one.json()["rules"].as_array().unwrap().len(), 1);
    let bad = r.req("PUT", &format!("/security_groups/{gid}/rules"), Some(json!({ "rules": [{ "action": "accept", "protocol": "SCTP", "direction": "inbound" }] })));
    assert_eq!(bad.json()["type"], "invalid_arguments");
    // A server on the group; the group cannot go while it is used.
    let s = r.req("POST", "/servers", Some(json!({ "name": "a", "commercial_type": "DEV1-S", "project": r.project, "image": "ubuntu_noble", "security_group": gid })));
    assert_eq!(s.json()["server"]["security_group"]["id"], gid.as_str());
    let used = r.req("DELETE", &format!("/security_groups/{gid}"), None);
    assert_eq!(used.status, 412, "{}", used.text());
    let sid = s.json()["server"]["id"].as_str().unwrap().to_string();
    assert_eq!(r.req("DELETE", &format!("/servers/{sid}"), None).status, 204);
    assert_eq!(r.req("DELETE", &format!("/security_groups/{gid}"), None).status, 204);
}

#[test]
fn the_catalog_answers_prices_in_eur_and_ram_in_bytes() {
    let r = Rig::new();
    let c = r.get("/products/servers");
    let dev = &c["servers"]["DEV1-S"];
    assert!(dev["hourly_price"].as_f64().unwrap() > 0.0 && dev["monthly_price"].as_f64().unwrap() > dev["hourly_price"].as_f64().unwrap());
    assert_eq!(dev["ncpus"], 2);
    assert_eq!(dev["ram"].as_u64(), Some(2 * 1024 * 1024 * 1024));
    let v = r.get("/products/volumes");
    assert!(v["volumes"]["b_ssd"]["hourly_price_per_gb"].as_f64().unwrap() > 0.0);
    assert_eq!(v["volumes"]["l_ssd"]["can_resize"], false);
    let z = call(&r.base, "GET", "/instance/v1/zones/xx-nope-9/servers", Some(&r.key), None, b"").unwrap();
    assert_eq!(z.status, 400, "an unknown zone");
}

#[test]
fn b12_user_data_is_text_plain_and_reads_back_as_written() {
    let r = Rig::new();
    let id = r.server("a", &[])["id"].as_str().unwrap().to_string();
    let seed = b"estate = \"acme.gunnar.example\"\nkind = \"gunnar\"\n";
    let path = format!("{Z}/servers/{id}/user_data/cloud-init");
    let refused = call(&r.base, "PATCH", &path, Some(&r.key), Some("application/json"), b"{}").unwrap();
    assert_eq!(refused.status, 400, "user data is text/plain");
    let w = call(&r.base, "PATCH", &path, Some(&r.key), Some("text/plain"), seed).unwrap();
    assert_eq!(w.status, 204, "{}", w.text());
    assert_eq!(r.get(&format!("/servers/{id}/user_data"))["user_data"], json!(["cloud-init"]));
    let back = call(&r.base, "GET", &path, Some(&r.key), None, b"").unwrap();
    assert_eq!((back.status, back.body.as_slice()), (200, &seed[..]));
    assert_eq!(call(&r.base, "DELETE", &path, Some(&r.key), None, b"").unwrap().status, 204);
    assert_eq!(call(&r.base, "GET", &path, Some(&r.key), None, b"").unwrap().status, 404);
}

#[test]
fn volumes_by_id_make_a_server_without_an_image_and_a_block_volume_grows() {
    let r = Rig::new();
    let v = r.req("POST", "/volumes", Some(json!({ "name": "acme-data", "project": r.project, "volume_type": "b_ssd", "size": 50_000_000_000u64, "tags": ["monetize_ref=t/acme"] })));
    assert_eq!(v.status, 201, "{}", v.text());
    let vid = v.json()["volume"]["id"].as_str().unwrap().to_string();
    let s = r.req("POST", "/servers", Some(json!({ "name": "acme", "commercial_type": "DEV1-S", "project": r.project, "volumes": { "0": { "id": vid } }, "dynamic_ip_required": false, "boot_type": "local" })));
    assert_eq!(s.status, 201, "{}", s.text());
    let sj = s.json()["server"].clone();
    assert_eq!(sj["volumes"]["0"]["id"], vid.as_str());
    assert_eq!(sj["public_ip"], Value::Null, "no dynamic ip when not asked");
    assert_eq!(r.get(&format!("/volumes/{vid}"))["volume"]["state"], "in_use");
    let grow = r.req("PATCH", &format!("/volumes/{vid}"), Some(json!({ "size": 80_000_000_000u64 })));
    assert_eq!(grow.json()["volume"]["size"], 80_000_000_000u64);
    let shrink = r.req("PATCH", &format!("/volumes/{vid}"), Some(json!({ "size": 10_000_000_000u64 })));
    assert_eq!(shrink.status, 400);
    let none = r.req("POST", "/servers", Some(json!({ "name": "x", "commercial_type": "DEV1-S", "project": r.project })));
    assert_eq!(none.json()["type"], "invalid_arguments", "neither an image nor a root volume");
    // A snapshot import from the mock's object store makes an image a server boots from.
    r.mock.put_object("media", "gunnar-dev.qcow2", b"bytes of a medium");
    let snap = r.req("POST", "/snapshots", Some(json!({ "name": "gunnar-dev", "project": r.project, "volume_type": "l_ssd", "bucket": "media", "key": "gunnar-dev.qcow2", "size": 20_000_000_000u64 })));
    assert_eq!(snap.status, 201, "{}", snap.text());
    let sid = snap.json()["snapshot"]["id"].as_str().unwrap().to_string();
    let early = r.req("POST", "/images", Some(json!({ "name": "gunnar-dev", "project": r.project, "root_volume": sid, "arch": "x86_64" })));
    assert_eq!(early.status, 409, "a snapshot still snapshotting: {}", early.text());
    r.mock.advance_ms(30_000);
    let img = r.req("POST", "/images", Some(json!({ "name": "gunnar-dev", "project": r.project, "root_volume": sid, "arch": "x86_64" })));
    assert_eq!(img.status, 201, "{}", img.text());
    let iid = img.json()["image"]["id"].as_str().unwrap().to_string();
    let from = r.req("POST", "/servers", Some(json!({ "name": "y", "commercial_type": "DEV1-S", "project": r.project, "image": iid })));
    assert_eq!(from.json()["server"]["image"]["id"], iid.as_str());
    assert_eq!(from.json()["server"]["volumes"]["0"]["size"], 20_000_000_000u64);
}