use mock_upcloud::estate::{Estate, StorageKind};
use mock_upcloud::{Clock, Fault, Faults, Mock};
use serde_json::Value;
struct Rig {
base: String,
http: reqwest::blocking::Client,
_rt: tokio::runtime::Runtime,
}
impl Rig {
fn with(faults: Faults) -> Rig {
let estate = Estate::new(Clock::virtual_only(), faults, 1234);
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
let port = rt.block_on(async {
let (p, _h) = mock_upcloud::serve(Mock::new(estate), "127.0.0.1:0").await.unwrap();
p
});
Rig {
base: format!("http://127.0.0.1:{port}"),
http: reqwest::blocking::Client::builder().build().unwrap(),
_rt: rt,
}
}
fn new() -> Rig {
Rig::with(Faults::none())
}
fn get(&self, path: &str) -> (u16, Value) {
let r = self
.http
.get(format!("{}{path}", self.base))
.bearer_auth("ucat_mock")
.send()
.unwrap();
let s = r.status().as_u16();
let t = r.text().unwrap();
(s, serde_json::from_str(&t).unwrap_or(Value::Null))
}
fn send(&self, method: reqwest::Method, path: &str, body: Value) -> (u16, Value) {
let r = self
.http
.request(method, format!("{}{path}", self.base))
.bearer_auth("ucat_mock")
.json(&body)
.send()
.unwrap();
let s = r.status().as_u16();
let t = r.text().unwrap();
(s, serde_json::from_str(&t).unwrap_or(Value::Null))
}
fn post(&self, path: &str, body: Value) -> (u16, Value) {
self.send(reqwest::Method::POST, path, body)
}
fn put(&self, path: &str, body: Value) -> (u16, Value) {
self.send(reqwest::Method::PUT, path, body)
}
fn delete(&self, path: &str) -> (u16, Value) {
self.send(reqwest::Method::DELETE, path, Value::Null)
}
fn a_started_server(&self, title: &str) -> String {
let (s, v) = self.post(
"/1.3/server",
serde_json::json!({"server": {
"zone": "se-sto1", "title": title, "hostname": title, "plan": "2xCPU-4GB",
"labels": {"label": [{"key": "site", "value": "gunnar.rs"}, {"key": "role", "value": "appliance"}]},
"storage_devices": {"storage_device": [{"action": "create", "title": format!("{title}-boot"), "size": 20, "tier": "maxiops"}]}
}}),
);
assert_eq!(s, 201, "{v}");
let uuid = v["server"]["uuid"].as_str().unwrap().to_string();
self.poll_until(&uuid, "started");
uuid
}
fn poll_until(&self, uuid: &str, state: &str) -> u32 {
for n in 1..=200 {
let (_, v) = self.get(&format!("/1.3/server/{uuid}"));
if v["server"]["state"] == state {
return n;
}
if v["server"].is_null() {
panic!("server {uuid} vanished while waiting for {state}");
}
}
panic!("server {uuid} never reached {state}");
}
}
#[test]
fn b01_a_deleted_servers_firewall_rules_answer_403_authentication_failed() {
let r = Rig::new();
let uuid = r.a_started_server("front");
let (s, _) = r.get(&format!("/1.3/server/{uuid}/firewall_rule"));
assert_eq!(s, 200);
assert_eq!(r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "hard", "timeout": "60"}})).0, 200);
r.poll_until(&uuid, "stopped");
assert_eq!(r.delete(&format!("/1.3/server/{uuid}?storages=1&backups=keep")).0, 204);
for _ in 0..200 {
let (s, _) = r.get(&format!("/1.3/server/{uuid}"));
if s == 404 {
break;
}
}
let (s, v) = r.get(&format!("/1.3/server/{uuid}/firewall_rule"));
assert_eq!(s, 403, "not 404 — that is the whole difficulty: {v}");
assert_eq!(
v["type"], "https://developers.upcloud.com/1.3/errors#ERROR_AUTHENTICATION_FAILED",
"{v}"
);
assert!(v["correlation_id"].is_string(), "{v}");
assert!(v["error"].is_null(), "it is problem+json, not the error envelope: {v}");
}
#[test]
fn b02_the_price_list_drops_the_socket_rather_than_answering() {
let f = Faults::none();
f.arm(Fault::PriceTransportReset);
let r = Rig::with(f);
let e = r
.http
.get(format!("{}/1.3/price", r.base))
.bearer_auth("ucat_mock")
.send()
.expect_err("the socket must close with nothing written");
assert!(e.status().is_none(), "a transport failure has no status: {e:?}");
}
#[test]
fn b03_poweron_is_refused_with_412_out_of_stock_and_stays_refused() {
let r = Rig::new();
let uuid = r.a_started_server("appliance");
r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "hard", "timeout": "60"}}));
r.poll_until(&uuid, "stopped");
let (s, v) = r.post(&format!("/mock/fault/out-of-stock/arm"), Value::Null);
assert_eq!(s, 200, "{v}");
for attempt in 0..25 {
let (s, v) = r.post(&format!("/1.3/server/{uuid}/start"), Value::Null);
assert_eq!(s, 412, "attempt {attempt}: a retry loop must not outwait it: {v}");
assert_eq!(v["error"]["error_code"], "out_of_stock", "{v}");
}
r.post("/mock/fault/out-of-stock/disarm", Value::Null);
assert_eq!(r.post(&format!("/1.3/server/{uuid}/start"), Value::Null).0, 200);
}
#[test]
fn b04_the_list_is_not_the_detail() {
let r = Rig::new();
let uuid = r.a_started_server("front");
let (_, list) = r.get("/1.3/server");
let row = &list["servers"]["server"][0];
assert_eq!(row["uuid"], uuid.as_str());
assert!(row["storage_devices"].is_null(), "the LIST must not carry attachments: {row}");
assert!(row["ip_addresses"].is_null(), "nor addresses: {row}");
let (_, detail) = r.get(&format!("/1.3/server/{uuid}"));
assert!(
detail["server"]["storage_devices"]["storage_device"][0]["storage"].is_string(),
"the DETAIL is the only place they exist: {detail}"
);
let (_, st) = r.get("/1.3/storage/private");
let s0 = &st["storages"]["storage"][0];
assert!(s0["labels"].is_array(), "{s0}");
}
#[test]
fn b05_a_revoked_credential_is_an_empty_account_not_an_error() {
let r = Rig::new();
let uuid = r.a_started_server("appliance");
assert_eq!(r.get("/1.3/server").1["servers"]["server"].as_array().unwrap().len(), 1);
r.post("/mock/fault/revoked-credential/arm", Value::Null);
let (s, v) = r.get("/1.3/server");
assert_eq!(s, 200, "not 401 — that is the point");
assert_eq!(v["servers"]["server"].as_array().unwrap().len(), 0, "{v}");
let (s, v) = r.get(&format!("/1.3/server/{uuid}"));
assert_eq!(s, 403, "{v}");
assert!(v["correlation_id"].is_string(), "{v}");
let (s, v) = r.get("/1.3/account");
assert_eq!(s, 403, "{v}");
}
#[test]
fn b06_a_server_is_in_maintenance_for_ninety_eight_to_a_hundred_and_five_seconds() {
let r = Rig::new();
let (_, v) = r.post(
"/1.3/server",
serde_json::json!({"server": {"zone": "se-sto1", "title": "t", "hostname": "t", "plan": "2xCPU-4GB",
"labels": {"label": []},
"storage_devices": {"storage_device": [{"action": "create", "title": "t-boot", "size": 20, "tier": "maxiops"}]}}}),
);
assert_eq!(v["server"]["state"], "maintenance", "it is never born started: {v}");
let uuid = v["server"]["uuid"].as_str().unwrap().to_string();
let polls = r.poll_until(&uuid, "started");
assert!(polls > 1, "a client that did not poll would have been wrong");
let (_, seed) = r.get("/mock/seed");
let elapsed = seed["now_ms"].as_u64().unwrap();
assert!((98_000..=106_000).contains(&elapsed), "{elapsed} ms");
}
#[test]
fn b07_and_b11_a_delete_needs_a_stop_first_and_a_fat_appliance_is_slow() {
let r = Rig::new();
let uuid = r.a_started_server("appliance");
let (s, v) = r.delete(&format!("/1.3/server/{uuid}?storages=1&backups=keep"));
assert_eq!(s, 409, "{v}");
assert_eq!(v["error"]["error_code"], "SERVER_STATE_ILLEGAL", "{v}");
r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "hard", "timeout": "60"}}));
r.poll_until(&uuid, "stopped");
for i in 0..4 {
let (s, sv) = r.post(
"/1.3/storage",
serde_json::json!({"storage": {"size": 100, "tier": "maxiops", "title": format!("member-{i}"), "zone": "se-sto1",
"labels": [{"key": "volume", "value": format!("member-{i}")}]}}),
);
assert_eq!(s, 201, "{sv}");
let su = sv["storage"]["uuid"].as_str().unwrap().to_string();
loop {
let (_, g) = r.get(&format!("/1.3/storage/{su}"));
if g["storage"]["state"] == "online" {
break;
}
}
let (s, av) = r.post(
&format!("/1.3/server/{uuid}/storage/attach"),
serde_json::json!({"storage_device": {"type": "disk", "storage": su}}),
);
assert_eq!(s, 200, "{av}");
}
let before = r.get("/mock/seed").1["now_ms"].as_u64().unwrap();
assert_eq!(r.delete(&format!("/1.3/server/{uuid}?storages=1&backups=keep")).0, 204);
let (_, v) = r.get(&format!("/1.3/server/{uuid}"));
assert_eq!(v["server"]["state"], "maintenance", "a 204 is not a deletion: {v}");
loop {
let (s, _) = r.get(&format!("/1.3/server/{uuid}"));
if s == 404 {
break;
}
}
let took = r.get("/mock/seed").1["now_ms"].as_u64().unwrap() - before;
assert!(took > 300_000, "four member volumes is over five minutes, not {took} ms");
}
#[test]
fn b08_the_install_is_ten_seconds_of_silence() {
use mock_upcloud::kvm::{GuestEngine, GuestOutcome, GuestSpec, VirtualGuest};
let out = VirtualGuest::default().boot(&GuestSpec::new("appliance", "/dev/null"));
assert_eq!(out, GuestOutcome::Installed { ms: 10_011 });
}
#[test]
fn b09_a_resize_leaves_a_backup_no_title_filter_can_see() {
let r = Rig::new();
let uuid = r.a_started_server("gunnar-appliance");
r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "soft", "timeout": "60"}}));
r.poll_until(&uuid, "stopped");
let before = r.get("/1.3/storage/private").1["storages"]["storage"].as_array().unwrap().len();
let (s, v) = r.put(&format!("/1.3/server/{uuid}"), serde_json::json!({"server": {"plan": "4xCPU-8GB"}}));
assert_eq!(s, 202, "{v}");
r.poll_until(&uuid, "stopped");
r.get("/1.3/server");
let (_, list) = r.get("/1.3/storage/private");
let rows = list["storages"]["storage"].as_array().unwrap();
assert_eq!(rows.len(), before + 1, "exactly one new object: {list}");
let backup = rows.iter().find(|s| s["type"] == "backup").expect("a backup row");
assert!(
!backup["title"].as_str().unwrap().to_lowercase().contains("gunnar"),
"a title-prefix filter is BLIND to this row, and that is the defect: {backup}"
);
assert!(backup["title"].as_str().unwrap().starts_with("Resize Backup"), "{backup}");
assert!(backup["origin"].is_string(), "it names an origin: {backup}");
let labels = backup["labels"].as_array().unwrap();
assert!(labels.iter().any(|l| l["key"] == "site"), "{backup}");
let (_, det) = r.get(&format!("/1.3/storage/{}", backup["uuid"].as_str().unwrap()));
assert_eq!(det["storage"]["servers"]["server"].as_array().unwrap().len(), 0, "{det}");
}
#[test]
fn b34_the_filesystem_resize_hands_back_the_backup_it_leaves_behind() {
let r = Rig::new();
let uuid = r.a_started_server("gunnar-twin");
let (_, det) = r.get(&format!("/1.3/server/{uuid}"));
let boot = det["server"]["storage_devices"]["storage_device"][0]["storage"].as_str().unwrap().to_string();
let (s, v) = r.post(&format!("/1.3/storage/{boot}/resize"), Value::Null);
assert_eq!(s, 409, "{v}");
assert_eq!(v["error"]["error_code"], "SERVER_STATE_ILLEGAL", "{v}");
r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "soft", "timeout": "60"}}));
r.poll_until(&uuid, "stopped");
assert_eq!(r.put(&format!("/1.3/storage/{boot}"), serde_json::json!({"storage": {"size": 44}})).0, 200);
let before = r.get("/1.3/storage/private").1["storages"]["storage"].as_array().unwrap().len();
let (s, v) = r.post(&format!("/1.3/storage/{boot}/resize"), Value::Null);
assert_eq!(s, 200, "{v}");
let backup = &v["resize_backup"];
let bk = backup["uuid"].as_str().expect("the reply names the backup it took").to_string();
assert_eq!(backup["type"], "backup", "{backup}");
assert_eq!(backup["size"], 44, "the backup is the volume's size — the 44 GB that was measured: {backup}");
assert_eq!(backup["origin"], boot, "and it names the volume it was taken from: {backup}");
assert!(backup["title"].as_str().unwrap().starts_with("Resize Backup"), "{backup}");
assert!(!backup["title"].as_str().unwrap().to_lowercase().contains("gunnar"), "provider-titled: {backup}");
assert!(backup["labels"].as_array().unwrap().iter().any(|l| l["key"] == "site"), "{backup}");
let (_, list) = r.get("/1.3/storage/private");
let rows = list["storages"]["storage"].as_array().unwrap();
assert_eq!(rows.len(), before + 1, "{list}");
let (_, det) = r.get(&format!("/1.3/storage/{bk}"));
assert_eq!(det["storage"]["servers"]["server"].as_array().unwrap().len(), 0, "{det}");
loop {
let st = r.get(&format!("/1.3/storage/{boot}")).1["storage"].clone();
if st["state"] == "online" {
assert_eq!(st["size"], 44);
break;
}
}
}
#[test]
fn b34b_unlabelled_is_a_hypothesis_and_arrives_only_when_asked_for() {
let r = Rig::new();
let uuid = r.a_started_server("gunnar-twin");
let boot = r.get(&format!("/1.3/server/{uuid}")).1["server"]["storage_devices"]["storage_device"][0]["storage"]
.as_str()
.unwrap()
.to_string();
r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "soft", "timeout": "60"}}));
r.poll_until(&uuid, "stopped");
r.post("/mock/fault/resize-backup-unlabelled/arm", Value::Null);
let (s, v) = r.post(&format!("/1.3/storage/{boot}/resize"), Value::Null);
assert_eq!(s, 200, "{v}");
let backup = &v["resize_backup"];
assert!(backup["labels"].as_array().unwrap().is_empty(), "nothing on it but the provider's own words: {backup}");
assert!(!backup["title"].as_str().unwrap().to_lowercase().contains("gunnar"), "{backup}");
assert_eq!(backup["origin"], boot, "the origin is all that is left to recognise it by: {backup}");
let seeded = mock_upcloud::Faults::seeded(7);
assert!(!seeded.is_armed(Fault::ResizeBackupUnlabelled));
assert!(!seeded.is_armed(Fault::WithholdCreatedField), "the same rule as the other hypothesis");
}
#[test]
fn b10_created_is_sent_on_both_the_list_and_the_detail() {
let r = Rig::new();
r.a_started_server("front");
let (_, list) = r.get("/1.3/storage/private");
let rows = list["storages"]["storage"].as_array().unwrap();
assert!(!rows.is_empty());
for row in rows {
let c = row["created"].as_str().unwrap_or_else(|| panic!("the account sends this: {row}"));
assert!(c.ends_with('Z') && c.contains('T'), "RFC 3339, as UpCloud sends it: {c}");
}
let uuid = rows[0]["uuid"].as_str().unwrap().to_string();
let (_, detail) = r.get(&format!("/1.3/storage/{uuid}"));
assert!(detail["storage"]["created"].is_string(), "and the DETAIL carries it too: {detail}");
r.post("/mock/fault/withhold-created-field/arm", Value::Null);
let (_, list) = r.get("/1.3/storage/private");
assert!(
list["storages"]["storage"][0]["created"].is_null(),
"armed by name, and only then: {list}"
);
}
#[test]
fn b12_the_addresses_are_not_the_same_on_the_next_lay() {
let r = Rig::new();
let a = r.a_started_server("appliance");
let t = r.a_started_server("twin");
let ip = |u: &str| {
let (_, v) = r.get(&format!("/1.3/server/{u}"));
let addrs = v["server"]["ip_addresses"]["ip_address"].as_array().unwrap().clone();
let f = |access: &str| {
addrs
.iter()
.find(|a| a["access"] == access)
.unwrap()["address"]
.as_str()
.unwrap()
.to_string()
};
(f("public"), f("utility"))
};
let lay1 = (ip(&a), ip(&t));
assert_ne!(lay1.0, lay1.1, "two servers never share an address");
r.post("/mock/relay", Value::Null);
let a2 = r.a_started_server("appliance");
let t2 = r.a_started_server("twin");
let lay2 = (ip(&a2), ip(&t2));
assert_ne!(lay1, lay2, "a re-lay that handed out the same addresses would hide the defect");
}
#[test]
fn b14_the_reported_vnc_port_is_the_one_from_before_the_restart() {
let r = Rig::new();
let uuid = r.a_started_server("appliance");
r.put(
&format!("/1.3/server/{uuid}"),
serde_json::json!({"server": {"remote_access_enabled": "yes", "remote_access_password": "hunter2"}}),
);
let port = |()| {
r.get(&format!("/1.3/server/{uuid}"))
.1["server"]["remote_access_port"]
.as_str()
.unwrap()
.to_string()
};
let first = port(());
r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "hard", "timeout": "60"}}));
r.poll_until(&uuid, "stopped");
r.post(&format!("/1.3/server/{uuid}/start"), Value::Null);
r.poll_until(&uuid, "started");
assert_eq!(port(()), first, "the API keeps reporting the OLD port — that is the defect");
r.put(&format!("/1.3/server/{uuid}"), serde_json::json!({"server": {"remote_access_enabled": "no"}}));
r.post("/mock/advance/2000", Value::Null);
r.put(&format!("/1.3/server/{uuid}"), serde_json::json!({"server": {"remote_access_enabled": "yes"}}));
assert_ne!(port(()), first, "after the toggle it reports the port the hypervisor is really on");
}
#[test]
fn b15_there_is_no_uefi_and_no_way_to_ask_for_one() {
let r = Rig::new();
let uuid = r.a_started_server("appliance");
let (_, v) = r.get(&format!("/1.3/server/{uuid}"));
assert_eq!(v["server"]["firmware"], "bios");
r.put(&format!("/1.3/server/{uuid}"), serde_json::json!({"server": {"firmware": "uefi"}}));
let (_, v) = r.get(&format!("/1.3/server/{uuid}"));
assert_eq!(v["server"]["firmware"], "bios", "the request was accepted and ignored");
}
#[test]
fn b16_and_b17_eject_is_the_only_never_loop_primitive() {
let f = Faults::none();
f.arm(Fault::InstallerLoop);
let r = Rig::with(f);
let uuid = r.a_started_server("appliance");
r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "hard", "timeout": "60"}}));
r.poll_until(&uuid, "stopped");
let (_, iso) = r.post(
"/1.3/storage",
serde_json::json!({"storage": {"size": 2, "tier": "maxiops", "title": "gunnar-installer.iso", "zone": "se-sto1", "labels": []}}),
);
let iso_uuid = iso["storage"]["uuid"].as_str().unwrap().to_string();
loop {
if r.get(&format!("/1.3/storage/{iso_uuid}")).1["storage"]["state"] == "online" {
break;
}
}
assert_eq!(
r.post(&format!("/1.3/server/{uuid}/storage/attach"), serde_json::json!({"storage_device": {"type": "cdrom", "storage": iso_uuid}})).0,
200
);
r.put(&format!("/1.3/server/{uuid}"), serde_json::json!({"server": {"boot_order": "cdrom"}}));
r.post(&format!("/1.3/server/{uuid}/start"), Value::Null);
r.poll_until(&uuid, "started");
let (s, v) = r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "soft", "timeout": "60"}}));
assert_eq!(s, 409, "a wedged installer answers no ACPI event: {v}");
let (s, v) = r.post(&format!("/1.3/server/{uuid}/storage/detach"), serde_json::json!({"storage_device": {"address": "ide:0:0"}}));
assert_eq!(s, 409, "{v}");
assert_eq!(v["error"]["error_code"], "IDE_HOTPLUG_UNSUPPORTED", "{v}");
let (s, v) = r.post(&format!("/1.3/server/{uuid}/cdrom/eject"), Value::Null);
assert_eq!(s, 200, "{v}");
let (_, est) = r.get("/mock/estate");
let me = est["servers"].as_array().unwrap().iter().find(|s| s["uuid"] == uuid.as_str()).unwrap();
assert_eq!(me["guest"], "Installed", "the loop ended: {me}");
}
#[test]
fn b22_a_volume_grows_and_never_shrinks() {
let r = Rig::new();
let (_, v) = r.post(
"/1.3/storage",
serde_json::json!({"storage": {"size": 100, "tier": "maxiops", "title": "data", "zone": "se-sto1", "labels": []}}),
);
let u = v["storage"]["uuid"].as_str().unwrap().to_string();
loop {
if r.get(&format!("/1.3/storage/{u}")).1["storage"]["state"] == "online" {
break;
}
}
assert_eq!(r.put(&format!("/1.3/storage/{u}"), serde_json::json!({"storage": {"size": 112}})).0, 200);
let (s, v) = r.put(&format!("/1.3/storage/{u}"), serde_json::json!({"storage": {"size": 100}}));
assert_eq!(s, 400, "{v}");
assert_eq!(v["error"]["error_code"], "SIZE_INVALID", "{v}");
assert!(
v["error"]["error_message"].as_str().unwrap().contains("must be greater than the old size"),
"refused BY NAME, so nobody believes a shrink happened: {v}"
);
let (s, v) = r.put(&format!("/1.3/storage/{u}"), serde_json::json!({"storage": {"size": 112}}));
assert_eq!((s, v["error"]["error_code"].as_str()), (400, Some("SIZE_INVALID")), "{v}");
assert_eq!(r.get(&format!("/1.3/storage/{u}")).1["storage"]["size"], 112);
}
#[test]
fn an_unimplemented_path_is_loud_and_names_itself() {
let r = Rig::new();
let (s, v) = r.get("/1.3/load_balancer");
assert_eq!(s, 404);
assert_eq!(v["error"]["error_code"], "MOCK_UPCLOUD_NOT_IMPLEMENTED");
assert!(v["error"]["error_message"].as_str().unwrap().contains("/1.3/load_balancer"), "{v}");
}
#[test]
fn no_credential_is_the_only_clean_401() {
let r = Rig::new();
let resp = r.http.get(format!("{}/1.3/server", r.base)).send().unwrap();
assert_eq!(resp.status().as_u16(), 401);
}
#[test]
fn a_put_that_names_the_same_plan_changes_nothing() {
let r = Rig::new();
let uuid = r.a_started_server("front");
r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "hard"}}));
for _ in 0..40 {
let (_, v) = r.get(&format!("/1.3/server/{uuid}"));
if v["server"]["state"] == "stopped" {
break;
}
}
let (_, before) = r.get(&format!("/1.3/server/{uuid}"));
assert_eq!(before["server"]["state"], "stopped", "the stop never completed: {}", before["server"]["state"]);
let plan = before["server"]["plan"].as_str().expect("a plan").to_string();
let backups_before = backup_count(&r);
let (code, _) = r.put(&format!("/1.3/server/{uuid}"), serde_json::json!({"server": {"plan": plan}}));
assert_eq!(code, 202, "behaviour 41: a server PUT is 202, MEASURED");
let (_, after) = r.get(&format!("/1.3/server/{uuid}"));
assert_eq!(after["server"]["state"], "stopped", "a no-op PUT moved the machine into {}", after["server"]["state"]);
assert_eq!(backup_count(&r), backups_before, "a no-op PUT minted a Resize Backup");
}
fn backup_count(r: &Rig) -> usize {
let (_, v) = r.get("/1.3/storage/private");
v["storages"]["storage"]
.as_array()
.map(|a| a.iter().filter(|s| s["type"] == "backup").count())
.unwrap_or(0)
}
#[test]
fn a_grown_volume_is_grown_everywhere_it_is_reported() {
let r = Rig::new();
let uuid = r.a_started_server("front");
let (_, v) = r.get(&format!("/1.3/server/{uuid}"));
let dev = v["server"]["storage_devices"]["storage_device"][0].clone();
let disk = dev["storage"].as_str().expect("a boot disk").to_string();
let was = dev["storage_size"].as_u64().expect("a number");
r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "hard"}}));
r.poll_until(&uuid, "stopped");
let (code, _) = r.put(&format!("/1.3/storage/{disk}"), serde_json::json!({"storage": {"size": was + 5}}));
assert_eq!(code, 200);
let (_, s) = r.get(&format!("/1.3/storage/{disk}"));
assert_eq!(s["storage"]["size"].as_u64(), Some(was + 5), "the volume itself");
let (_, v2) = r.get(&format!("/1.3/server/{uuid}"));
let after = v2["server"]["storage_devices"]["storage_device"][0]["storage_size"].as_u64();
assert_eq!(after, Some(was + 5), "the DEVICE still reports the old size: {}", v2["server"]["storage_devices"]);
}
#[test]
fn the_inconsistent_stringification_is_reproduced() {
let r = Rig::new();
let uuid = r.a_started_server("front");
let (_, v) = r.get(&format!("/1.3/server/{uuid}"));
let s = &v["server"];
assert!(s["core_number"].is_string(), "core_number is a STRING: {s}");
assert!(s["memory_amount"].is_string(), "{s}");
assert!(
s["storage_devices"]["storage_device"][0]["storage_size"].is_number(),
"a device's storage_size is a NUMBER — the official provider's SDK declares it int: {s}"
);
let (_, st) = r.get("/1.3/storage/private");
assert!(st["storages"]["storage"][0]["size"].is_number(), "but a storage's size is a NUMBER");
}
#[test]
fn the_public_templates_are_in_the_bare_storage_list() {
let r = Rig::new();
let all = r.get("/1.3/storage").1["storages"]["storage"].as_array().unwrap().len();
let mine = r.get("/1.3/storage/private").1["storages"]["storage"].as_array().unwrap().len();
assert!(all > mine, "{all} vs {mine}");
}
#[test]
fn a_seed_reproduces_the_estate() {
let one = Estate::new(Clock::virtual_only(), Faults::seeded(77), 77);
let two = Estate::new(Clock::virtual_only(), Faults::seeded(77), 77);
let names = |e: &Estate| {
e.all_storages()
.filter(|s| s.kind == StorageKind::Template)
.map(|s| s.uuid.clone())
.collect::<Vec<_>>()
};
assert_eq!(names(&one), names(&two));
}
fn an_iso() -> Vec<u8> {
let mut v = vec![0u8; 43_485_184];
for (i, b) in v.iter_mut().enumerate() {
*b = (i % 251) as u8;
}
v
}
fn upload_a_medium(r: &Rig, title: &str) -> (Value, String) {
let (s, v) = r.post(
"/1.3/storage",
serde_json::json!({"storage": {"size": 1, "tier": "maxiops", "title": title, "zone": "se-sto1", "labels": []}}),
);
assert_eq!(s, 201, "{v}");
let u = v["storage"]["uuid"].as_str().unwrap().to_string();
loop {
if r.get(&format!("/1.3/storage/{u}")).1["storage"]["state"] == "online" {
break;
}
}
let (s, sess) = r.post(
&format!("/1.3/storage/{u}/import"),
serde_json::json!({"storage_import": {"source": "direct_upload"}}),
);
assert_eq!(s, 201, "{sess}");
let url = sess["storage_import"]["direct_upload_url"].as_str().unwrap().to_string();
let resp = r.http.put(&url).body(an_iso()).send().unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = serde_json::from_str(&resp.text().unwrap()).unwrap();
assert!(body.get("storage_import").is_none() && body.get("written_bytes").is_some(), "{body}");
let im = body;
(im, u)
}
#[test]
fn b23_the_import_says_completed_while_the_storage_is_still_syncing() {
let r = Rig::new();
let (im, u) = upload_a_medium(&r, "gunnar-installer.iso");
assert_eq!(im["read_bytes"], 43_485_184u64);
assert_eq!(im["written_bytes"], 43_485_184u64);
let t0 = r.get("/mock/seed").1["now_ms"].as_u64().unwrap();
let (mut import_done_at, mut online_at) = (None, None);
let mut saw_syncing_while_completed = false;
for _ in 0..500 {
let now = r.get("/mock/seed").1["now_ms"].as_u64().unwrap();
let (_, v) = r.get(&format!("/1.3/storage/{u}"));
let st = v["storage"]["state"].as_str().unwrap().to_string();
let ist = v["storage"]["storage_import"]["state"].as_str().unwrap_or("").to_string();
if ist == "completed" && import_done_at.is_none() {
import_done_at = Some(now);
}
if ist == "completed" && st == "syncing" {
saw_syncing_while_completed = true;
}
if st == "online" {
online_at = Some(now);
break;
}
}
let import_done_at = import_done_at.expect("the import must complete");
let online_at = online_at.expect("the storage must eventually come online");
assert!(
saw_syncing_while_completed,
"the whole point: the import says completed while the storage says syncing"
);
let upload_took = import_done_at - t0;
let sync_took = online_at - import_done_at;
assert!((4_000..=6_500).contains(&upload_took), "the upload is five seconds: {upload_took} ms");
assert!(
(95_000..=140_000).contains(&sync_took),
"and then a hundred-and-something seconds of nothing: {sync_took} ms"
);
assert!(sync_took > upload_took * 15, "the WAIT is the cost, not the transfer");
}
#[test]
fn b24_the_import_carries_the_real_digests_of_the_real_bytes() {
let r = Rig::new();
let (im, _) = upload_a_medium(&r, "iso");
let bytes = an_iso();
assert_eq!(
im["sha256sum"].as_str().unwrap(),
mock_upcloud::digest::sha256_hex(&bytes)
);
assert_eq!(
im["md5sum"].as_str().unwrap(),
mock_upcloud::digest::md5_hex(&bytes)
);
let mut other = bytes.clone();
other[0] ^= 0xff;
assert_ne!(mock_upcloud::digest::sha256_hex(&other), mock_upcloud::digest::sha256_hex(&bytes));
}
#[test]
fn b25_the_same_medium_is_uploaded_twice_and_waits_twice() {
let r = Rig::new();
let t0 = r.get("/mock/seed").1["now_ms"].as_u64().unwrap();
for title in ["gunnar-installer.iso (cdrom)", "gunnar-installer.iso (virtio)"] {
let (_, u) = upload_a_medium(&r, title);
loop {
if r.get(&format!("/1.3/storage/{u}")).1["storage"]["state"] == "online" {
break;
}
}
}
let took = r.get("/mock/seed").1["now_ms"].as_u64().unwrap() - t0;
assert!(took > 200_000, "two media is over three minutes of waiting: {took} ms");
}
#[test]
fn b26_a_clone_skips_the_sync_and_goes_online_in_forty_seven_seconds() {
let r = Rig::new();
let (im, first) = upload_a_medium(&r, "gunnar-installer.iso (cdrom)");
loop {
if r.get(&format!("/1.3/storage/{first}")).1["storage"]["state"] == "online" {
break;
}
}
let sha = im["sha256sum"].as_str().unwrap().to_string();
let clone_of = |title: &str| -> (u64, bool) {
let t0 = r.get("/mock/seed").1["now_ms"].as_u64().unwrap();
let (s, v) = r.post(
&format!("/1.3/storage/{first}/clone"),
serde_json::json!({"storage": {"title": title, "tier": "maxiops"}}),
);
assert_eq!(s, 201, "{v}");
let c = v["storage"]["uuid"].as_str().unwrap().to_string();
let mut saw_syncing = false;
loop {
let (_, g) = r.get(&format!("/1.3/storage/{c}"));
if g["storage"]["state"] == "syncing" {
saw_syncing = true;
}
if g["storage"]["state"] == "online" {
assert_eq!(g["storage"]["storage_import"]["sha256sum"].as_str().unwrap(), sha, "same bytes");
assert_eq!(g["storage"]["origin"].as_str().unwrap(), first);
break;
}
}
(r.get("/mock/seed").1["now_ms"].as_u64().unwrap() - t0, saw_syncing)
};
let (took, synced) = clone_of("gunnar-installer.iso (virtio)");
assert!(!synced, "MEASURED: a clone never enters `syncing`");
assert!((40_000..=60_000).contains(&took), "MEASURED: online in ~47 s, not {took} ms");
assert_eq!(r.post("/mock/fault/clone-skips-sync/arm", Value::Null).0, 404, "the old name is gone, loudly");
assert_eq!(r.post("/mock/fault/clone-syncs-like-import/arm", Value::Null).0, 200);
let (took2, synced2) = clone_of("third");
assert!(synced2 && took2 > 95_000, "the OLD guess: syncs like an import ({took2} ms)");
}
#[test]
fn a_sync_can_exceed_the_callers_budget() {
let f = Faults::none();
f.arm(Fault::SyncExceedsBudget);
let r = Rig::with(f);
let (_, u) = upload_a_medium(&r, "iso");
let t0 = r.get("/mock/seed").1["now_ms"].as_u64().unwrap();
loop {
if r.get(&format!("/1.3/storage/{u}")).1["storage"]["state"] == "online" {
break;
}
}
let took = r.get("/mock/seed").1["now_ms"].as_u64().unwrap() - t0;
assert!(took > 1_200_000, "past the 1200 s budget: {took} ms");
}
#[test]
fn an_import_can_fail_after_the_bytes_arrived() {
let f = Faults::none();
f.arm(Fault::ImportFailed);
let r = Rig::with(f);
let (_, u) = upload_a_medium(&r, "iso");
let mut state = String::new();
for _ in 0..200 {
let (_, v) = r.get(&format!("/1.3/storage/{u}"));
state = v["storage"]["state"].as_str().unwrap().to_string();
if state == "error" {
assert_eq!(v["storage"]["storage_import"]["state"], "failed", "{v}");
assert_eq!(v["storage"]["storage_import"]["error_code"], "IMPORT_FAILED", "{v}");
assert!(v["storage"]["storage_import"]["error_message"].is_string(), "{v}");
assert_eq!(v["storage"]["storage_import"]["read_bytes"], 43_485_184u64, "{v}");
return;
}
}
panic!("the storage never reached error; it is {state}");
}
#[test]
fn b19_the_appliance_misreads_the_rtc_and_the_front_does_not() {
let f = Faults::none();
f.arm(Fault::GuestReadsRtcAsLocalTime);
let r = Rig::with(f);
let appliance = r.a_started_server("appliance");
r.post("/mock/fault/guest-reads-rtc-as-local-time/disarm", Value::Null);
let front = r.a_started_server("front");
for u in [&appliance, &front] {
let (_, v) = r.get(&format!("/1.3/server/{u}"));
assert_eq!(v["server"]["timezone"], "UTC", "the hypervisor tells the truth: {v}");
}
let (_, a) = r.get(&format!("/mock/clock/{appliance}"));
let (_, f2) = r.get(&format!("/mock/clock/{front}"));
assert_eq!(a["guest_clock_skew_ms"], 7_200_000i64, "measured +7 198 668 ms; the mechanism is exactly 2 h: {a}");
assert_eq!(f2["guest_clock_skew_ms"], 0i64, "same hypervisor, same zone, correct: {f2}");
assert_eq!(a["hypervisor_rtc"], "UTC");
assert_eq!(a["guest_reads_rtc_as"], "LocalTime");
assert_eq!(f2["guest_reads_rtc_as"], "Utc");
}
#[test]
fn b18_no_udp_reply_comes_back_so_ntp_is_useless() {
let r = Rig::new();
let u = r.a_started_server("appliance");
let (_, v) = r.get(&format!("/mock/clock/{u}"));
assert_eq!(v["udp_reply_arrives"], false, "default ON: it is the provider's normal, not an exception");
}
#[test]
fn b27_one_wrong_clock_is_six_reds_in_causal_order() {
let f = Faults::none();
f.arm(Fault::GuestReadsRtcAsLocalTime);
let r = Rig::with(f);
let u = r.a_started_server("appliance");
let (_, v) = r.get(&format!("/mock/clock/{u}"));
let rows: Vec<String> = v["red"]
.as_array()
.unwrap()
.iter()
.map(|x| x["row"].as_str().unwrap().to_string())
.collect();
assert_eq!(
rows,
vec!["skew-refusal", "no-quorum", "clock-unchanged", "console-key", "banner", "monetize-poll"],
"{v}"
);
let console = v["red"].as_array().unwrap().iter().find(|x| x["row"] == "console-key").unwrap();
assert!(
console["why"].as_str().unwrap().contains("the window is ±300000 ms"),
"the measured sentence, verbatim: {console}"
);
r.post("/mock/fault/guest-reads-rtc-as-local-time/disarm", Value::Null);
let front = r.a_started_server("front");
let (_, v) = r.get(&format!("/mock/clock/{front}"));
assert_eq!(v["red"].as_array().unwrap().len(), 0, "{v}");
}
fn a_straddling_pair(r: &Rig, first: &str, rest: &[&str]) -> (String, String) {
let a = r.a_started_server(first);
let net_of = |u: &str| {
let (_, v) = r.get(&format!("/mock/dhcp/{u}"));
let ip = v["address"].as_str().unwrap().to_string();
(ip.clone(), mock_upcloud::net::net_of(&ip).unwrap().to_string())
};
let (_, a_net) = net_of(&a);
for t in rest {
let b = r.a_started_server(t);
let (_, b_net) = net_of(&b);
if b_net != a_net {
return (a, b);
}
}
panic!("the utility pool must span two /22s or behaviour 29 is unreachable");
}
#[test]
fn b29_a_guest_that_ignores_option_121_dies_outbound_and_looks_healthy_inbound() {
let f = Faults::none();
f.arm(Fault::GuestIgnoresDhcpOption121);
let r = Rig::with(f);
let appliance = r.a_started_server("appliance");
r.post("/mock/fault/guest-ignores-dhcp-option-121/disarm", Value::Null);
let (_, offer) = r.get(&format!("/mock/dhcp/{appliance}"));
assert!(offer["router"].is_null(), "no default gateway: that is why option 121 is load-bearing: {offer}");
assert_eq!(offer["option_121"].as_array().unwrap().len(), 1, "the offer IS complete: {offer}");
assert_eq!(offer["guest_dhcp_client"], "IgnoresOption121", "…and the guest is what drops it: {offer}");
let a_ip = offer["address"].as_str().unwrap().to_string();
let a_net = mock_upcloud::net::net_of(&a_ip).unwrap().to_string();
let mut front = None;
for t in ["front-a", "front-b", "front-c", "front-d", "front-e"] {
let u = r.a_started_server(t);
let (_, o) = r.get(&format!("/mock/dhcp/{u}"));
let ip = o["address"].as_str().unwrap().to_string();
if mock_upcloud::net::net_of(&ip).unwrap().to_string() != a_net {
front = Some((u, ip));
break;
}
}
let (front_uuid, front_ip) = front.expect("the pool spans two /22s");
let (_, out) = r.get(&format!("/mock/reach/{appliance}?dest={front_ip}&port=443"));
assert_eq!(out["ok"], false, "{out}");
assert_eq!(out["kind"], "no-route-outbound", "{out}");
assert!(out["why"].as_str().unwrap().contains("did not install it"), "{out}");
assert_eq!(out["inbound_ok"], true, "every health check says this box is up: {out}");
let (_, back) = r.get(&format!("/mock/reach/{front_uuid}?dest={a_ip}&port=443"));
assert_eq!(back["ok"], true, "{back}");
}
#[test]
fn b30_the_box_holding_the_dnat_cannot_use_it_on_itself() {
let r = Rig::new();
let (front, appliance) = a_straddling_pair(&r, "front", &["appliance-a", "appliance-b", "appliance-c", "appliance-d"]);
let (_, av) = r.get(&format!("/1.3/server/{appliance}"));
let a_util = av["server"]["ip_addresses"]["ip_address"]
.as_array()
.unwrap()
.iter()
.find(|x| x["access"] == "utility")
.unwrap()["address"]
.as_str()
.unwrap()
.to_string();
let (_, fv) = r.get(&format!("/1.3/server/{front}"));
let f_pub = fv["server"]["ip_addresses"]["ip_address"]
.as_array()
.unwrap()
.iter()
.find(|x| x["access"] == "public")
.unwrap()["address"]
.as_str()
.unwrap()
.to_string();
assert_eq!(r.post(&format!("/mock/dnat/{front}/2222/{a_util}"), Value::Null).0, 200);
let (_, own) = r.get(&format!("/mock/reach/{front}?dest={f_pub}&port=2222"));
assert_eq!(own["kind"], "no-hairpin", "{own}");
assert!(own["why"].as_str().unwrap().contains("connection refused"), "the kernel's own words: {own}");
assert!(own["why"].as_str().unwrap().contains("prerouting"), "and the reason: {own}");
let (_, outside) = r.get(&format!("/mock/reach/{appliance}?dest={f_pub}&port=2222"));
assert_eq!(outside["ok"], true, "{outside}");
}
#[test]
fn b31_the_console_is_a_zone_host_and_a_port_and_both_are_the_providers() {
let r = Rig::new();
let u = r.a_started_server("appliance");
let (_, v) = r.get(&format!("/1.3/server/{u}"));
assert_eq!(v["server"]["remote_access_enabled"], "no");
assert!(v["server"]["remote_access_host"].is_null(), "no stale host: {v}");
assert!(v["server"]["remote_access_port"].is_null(), "no stale port: {v}");
r.put(&format!("/1.3/server/{u}"), serde_json::json!({"server": {"remote_access_enabled": "yes"}}));
r.put(&format!("/1.3/server/{u}"), serde_json::json!({"server": {"remote_access_password": "hunter2"}}));
let (_, v) = r.get(&format!("/1.3/server/{u}"));
let host = v["server"]["remote_access_host"].as_str().unwrap().to_string();
let port: u16 = v["server"]["remote_access_port"].as_str().unwrap().parse().unwrap();
assert_eq!(host, "se-sto1.vnc.mock.invalid", "the ZONE's console (RFC 2606 in the mock), not the server's own address: {v}");
assert!(!host.contains(&u[..8]), "a client that builds the host from the uuid is wrong: {host}");
assert!((60_000..61_000).contains(&port), "five figures, as measured: {port}");
r.post(&format!("/1.3/server/{u}/stop"), serde_json::json!({"stop_server": {"stop_type": "hard", "timeout": "60"}}));
r.poll_until(&u, "stopped");
r.post(&format!("/1.3/server/{u}/start"), Value::Null);
r.poll_until(&u, "started");
let (_, v) = r.get(&format!("/1.3/server/{u}"));
assert_eq!(v["server"]["remote_access_port"].as_str().unwrap().parse::<u16>().unwrap(), port, "stale, as before");
r.put(&format!("/1.3/server/{u}"), serde_json::json!({"server": {"remote_access_enabled": "no"}}));
r.post("/mock/advance/2000", Value::Null);
r.put(&format!("/1.3/server/{u}"), serde_json::json!({"server": {"remote_access_enabled": "yes"}}));
let (_, v) = r.get(&format!("/1.3/server/{u}"));
assert_ne!(
v["server"]["remote_access_port"].as_str().unwrap().parse::<u16>().unwrap(),
port,
"the cure re-provisions the endpoint: {v}"
);
assert!(v["server"]["remote_access_host"].is_string(), "and hands back a host with it: {v}");
}
#[test]
fn b32_the_guests_install_and_the_ladders_wall_are_two_orders_of_magnitude_apart() {
let r = Rig::new();
let u = r.a_started_server("appliance");
let (_, v) = r.get(&format!("/mock/reimage/{u}"));
let uart = v["guest_uart_ms"].as_u64().unwrap();
let wall = v["ladder_wall_ms"].as_u64().unwrap();
assert!((2_001..=4_752).contains(&uart), "the guest's own clock: {v}");
assert!((110_000..=253_000).contains(&wall), "the ladder's wall clock: {v}");
assert!(wall > uart * 20, "two orders of magnitude, and it is the provider's: {v}");
assert!(v["observers"]["guest_uart_ms"].is_string(), "each number names who measured it: {v}");
assert!(v["observers"]["ladder_wall_ms"].is_string(), "{v}");
assert!(wall > 60_000, "a 60 s bar reads RED forever against this observer: {wall}");
}
#[test]
fn b33_three_paths_one_key_and_a_reimage_changes_it() {
let r = Rig::new();
let u = r.a_started_server("appliance");
let (s, v) = r.get(&format!("/mock/hostkey/{u}"));
assert_eq!(s, 200, "{v}");
assert_eq!(v["agree"], true, "{v}");
let first = v["paths"]["name"].as_str().unwrap().to_string();
assert_eq!(v["paths"]["front"].as_str().unwrap(), first);
assert_eq!(v["paths"]["direct"].as_str().unwrap(), first);
assert!(first.starts_with("SHA256:"), "{first}");
r.post(&format!("/1.3/server/{u}/stop"), serde_json::json!({"stop_server": {"stop_type": "hard", "timeout": "60"}}));
r.poll_until(&u, "stopped");
r.post(&format!("/1.3/server/{u}/start"), Value::Null);
r.poll_until(&u, "started");
let (_, v) = r.get(&format!("/mock/hostkey/{u}"));
assert_ne!(v["paths"]["direct"].as_str().unwrap(), first, "a re-image mints a new key: {v}");
assert_eq!(v["agree"], true, "…and all three paths still agree, so it is the same machine: {v}");
}
#[test]
fn b33b_a_hijacked_name_is_caught_only_because_three_paths_are_checked() {
let f = Faults::none();
f.arm(Fault::HijackedName);
let r = Rig::with(f);
let u = r.a_started_server("appliance");
let (_, v) = r.get(&format!("/mock/hostkey/{u}"));
assert_eq!(v["agree"], false, "{v}");
assert_ne!(v["paths"]["name"], v["paths"]["direct"], "{v}");
assert_eq!(v["paths"]["front"], v["paths"]["direct"], "only the NAME is lying: {v}");
assert!(v["verdict"].as_str().unwrap().contains("not behind the DNAT"), "{v}");
}
#[test]
fn b35_a_live_servers_firewall_answers_403_under_a_scoped_credential() {
let f = Faults::none();
f.arm(Fault::FirewallForbidden);
let r = Rig::with(f);
let uuid = r.a_started_server("front");
let (s, v) = r.get(&format!("/1.3/server/{uuid}"));
assert_eq!(s, 200, "the server itself reads fine: {v}");
assert_eq!(v["server"]["uuid"], uuid.as_str());
let (s, fw) = r.get(&format!("/1.3/server/{uuid}/firewall_rule"));
assert_eq!(s, 403, "{fw}");
assert!(fw["type"].as_str().unwrap().ends_with("ERROR_AUTHENTICATION_FAILED"), "{fw}");
assert!(fw["correlation_id"].is_string(), "{fw}");
let (_, list) = r.get("/1.3/server");
let listed: Vec<&str> = list["servers"]["server"].as_array().unwrap().iter().filter_map(|s| s["uuid"].as_str()).collect();
assert_eq!(listed, vec![uuid.as_str()]);
let (s, dead) = r.get("/1.3/server/00000000-0000-4000-8000-000000000000/firewall_rule");
assert_eq!(s, 403);
assert_eq!(dead["type"], fw["type"]);
assert_eq!(dead["title"], fw["title"]);
}
#[test]
fn b36_the_detail_answers_404_for_a_server_the_list_shows() {
let r = Rig::new();
let uuid = r.a_started_server("appliance");
r.post("/mock/fault/detail-404-for-listed-server/arm", Value::Null);
let (_, list) = r.get("/1.3/server");
let listed: Vec<&str> = list["servers"]["server"].as_array().unwrap().iter().filter_map(|s| s["uuid"].as_str()).collect();
assert_eq!(listed, vec![uuid.as_str()], "the list shows it");
let (s, v) = r.get(&format!("/1.3/server/{uuid}"));
assert_eq!(s, 404, "{v}");
assert_eq!(v["error"]["error_code"], "SERVER_NOT_FOUND", "{v}");
let (s, never) = r.get("/1.3/server/00000000-0000-4000-8000-000000000000");
assert_eq!(s, 404);
assert_eq!(never["error"]["error_code"], v["error"]["error_code"]);
}
#[test]
fn b01b_a_stale_uuid_reads_404_and_its_firewall_403_while_the_title_is_alive_under_another_uuid() {
let r = Rig::new();
let live = r.a_started_server("gunnar-front");
let stale = "0031e940-3f7d-4116-bc74-b8131f6d07a3";
assert_ne!(live, stale);
let (s, v) = r.get(&format!("/1.3/server/{stale}"));
assert_eq!(s, 404, "{v}");
assert_eq!(v["error"]["error_code"], "SERVER_NOT_FOUND");
let (s, fw) = r.get(&format!("/1.3/server/{stale}/firewall_rule"));
assert_eq!(s, 403, "{fw}");
assert!(fw["correlation_id"].is_string(), "{fw}");
let (_, list) = r.get("/1.3/server");
let rows = list["servers"]["server"].as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["title"], "gunnar-front");
assert_eq!(rows[0]["uuid"], live.as_str(), "alive, and NOT under the state's uuid");
}
#[test]
fn b37_a_sold_out_zone_refuses_the_create_and_the_same_call_succeeds_when_it_is_not() {
let body = serde_json::json!({"server": {
"zone": "se-sto1", "title": "gunnar-front", "hostname": "gunnar-front", "plan": "2xCPU-4GB",
"storage_devices": {"storage_device": [{"action": "create", "title": "boot", "size": 20, "tier": "maxiops"}]}
}});
let r = Rig::new();
let (s, v) = r.post("/1.3/server", body.clone());
assert_eq!(s, 201, "with the zone in stock this is a create like any other: {v}");
assert!(v["server"]["uuid"].is_string(), "{v}");
assert_eq!(r.get("/1.3/server").1["servers"]["server"].as_array().unwrap().len(), 1);
let r = Rig::new();
r.post("/mock/fault/out-of-stock/arm", Value::Null);
let (s, v) = r.post("/1.3/server", body.clone());
assert_eq!(s, 412, "the create needs the capacity too: {v}");
assert_eq!(v["error"]["error_code"], "out_of_stock", "{v}");
assert_eq!(r.get("/1.3/server").1["servers"]["server"].as_array().unwrap().len(), 0);
assert_eq!(r.get("/1.3/storage/private").1["storages"]["storage"].as_array().unwrap().len(), 0);
for attempt in 0..25 {
let (s, v) = r.post("/1.3/server", body.clone());
assert_eq!(s, 412, "attempt {attempt}: {v}");
}
r.post("/mock/fault/out-of-stock/disarm", Value::Null);
let (s, v) = r.post("/1.3/server", body);
assert_eq!(s, 201, "{v}");
let uuid = v["server"]["uuid"].as_str().unwrap().to_string();
r.poll_until(&uuid, "started");
}
#[test]
fn b38_a_detach_can_answer_success_and_leave_the_volume_attached() {
let r = Rig::new();
let uuid = r.a_started_server("gunnar-twin");
let member = attach_a_member(&r, &uuid, "twin-data");
let before = devices(&r, &uuid);
assert!(before.contains(&member.clone()), "the member is on the server to start with: {before:?}");
r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "soft", "timeout": "60"}}));
r.poll_until(&uuid, "stopped");
let (s, v) = r.post(
&format!("/1.3/server/{uuid}/storage/detach"),
serde_json::json!({"storage_device": {"address": "virtio:1"}}),
);
assert_eq!(s, 200, "{v}");
let after = devices(&r, &uuid);
assert!(!after.contains(&member), "disarmed, the read-back shows it GONE: {after:?}");
assert_eq!(after.len(), before.len() - 1);
let r = Rig::new();
let uuid = r.a_started_server("gunnar-twin");
let member = attach_a_member(&r, &uuid, "twin-data");
r.post(&format!("/1.3/server/{uuid}/stop"), serde_json::json!({"stop_server": {"stop_type": "soft", "timeout": "60"}}));
r.poll_until(&uuid, "stopped");
r.post("/mock/fault/detach-says-success/arm", Value::Null);
let (s, v) = r.post(
&format!("/1.3/server/{uuid}/storage/detach"),
serde_json::json!({"storage_device": {"address": "virtio:1"}}),
);
assert_eq!(s, 200, "the lie is a clean success, not an error: {v}");
assert!(v["server"]["uuid"].is_string(), "and it carries the server object, like the real one: {v}");
let after = devices(&r, &uuid);
assert!(after.contains(&member), "armed, the volume is STILL ATTACHED: {after:?}");
let (s, v) = r.post(
&format!("/1.3/server/{uuid}/storage/detach"),
serde_json::json!({"storage_device": {"address": "virtio:9"}}),
);
assert_eq!(s, 404, "an address that is not there is still a 404: {v}");
assert_eq!(v["error"]["error_code"], "STORAGE_DEVICE_NOT_FOUND", "{v}");
r.post("/mock/fault/detach-says-success/disarm", Value::Null);
let (s, v) = r.post(
&format!("/1.3/server/{uuid}/storage/detach"),
serde_json::json!({"storage_device": {"address": "virtio:1"}}),
);
assert_eq!(s, 200, "{v}");
assert!(!devices(&r, &uuid).contains(&member), "and this time it is gone");
assert!(!mock_upcloud::Faults::seeded(7).is_armed(Fault::DetachSaysSuccessButStaysAttached));
}
fn attach_a_member(r: &Rig, server: &str, title: &str) -> String {
let (s, v) = r.post(
"/1.3/storage",
serde_json::json!({"storage": {"title": title, "size": 10, "tier": "maxiops", "zone": "se-sto1"}}),
);
assert_eq!(s, 201, "{v}");
let uuid = v["storage"]["uuid"].as_str().unwrap().to_string();
for _ in 0..200 {
if r.get(&format!("/1.3/storage/{uuid}")).1["storage"]["state"] == "online" {
break;
}
}
let (s, v) = r.post(
&format!("/1.3/server/{server}/storage/attach"),
serde_json::json!({"storage_device": {"storage": uuid, "type": "disk"}}),
);
assert_eq!(s, 200, "{v}");
uuid
}
fn devices(r: &Rig, server: &str) -> Vec<String> {
r.get(&format!("/1.3/server/{server}")).1["server"]["storage_devices"]["storage_device"]
.as_array()
.unwrap()
.iter()
.filter_map(|d| d["storage"].as_str().map(str::to_string))
.collect()
}