mock-upcloud 0.1.3

A faithful fake of the UpCloud API 1.3 — the lies included — backed by real KVM guests
Documentation
//! **`FakeUpCloud` — the mock's in-process face on `upcloud_api::UpCloudApi`.**
//!
//! One world, two faces. [`crate::http`]'s socket is the face for terraform — a
//! separate process that cannot hold a Rust trait object, which is the single
//! legitimate reason for a wire-level fake. This is the face for Rust callers in
//! the SAME process: every trait call becomes the same `upcloud_api::Call` the
//! production wire sends (the path spelling lives in edda's `upcloud_api::over`,
//! once), and that call is handed to the same door the socket parser uses —
//! [`crate::http::answer`]: the same router, the same `Estate` methods, the same
//! guest engine, the same behaviour-63 `heard` bookkeeping, the same recorded
//! calls. A fault armed once applies to both faces; a VM a Rust caller starts
//! is the VM terraform then reads.
//!
//! Nothing is re-implemented here, deliberately. A trait face that called
//! `Estate` methods directly would be a SECOND interpretation of each request —
//! the 202 on `PUT /server`, the 511 on a hotplug the guest kernel lacks, the
//! 409 `STORAGE_ATTACHED` — and the first time the two disagreed, a Rust test
//! would be green against a world terraform never sees.

use std::sync::Arc;

use serde_json::Value;
use upcloud_api::{Call, Exchange, Over, Reply};

use crate::http::{answer, Mock, Outcome};

/// The in-process face. Build it with [`FakeUpCloud::over`] and hold the
/// result as an `upcloud_api::UpCloudApi`.
pub struct FakeUpCloud {
    mock: Arc<Mock>,
    /// The bearer this face presents — per run, so `/mock/heard` can tell this
    /// caller's traffic from anybody else's (behaviour 63).
    credential: String,
}

impl FakeUpCloud {
    /// The trait face over `mock`, presenting `credential` as its bearer.
    ///
    /// If the mock has never been served on a socket, its import replies would
    /// name a real-looking `https://<zone>.img.upcloud.com/…` upload URL; the
    /// face gives the estate an in-process upload base instead, so a Rust
    /// caller that FOLLOWS the URL it was answered (rather than building one)
    /// lands back here. A served mock keeps its socket's base.
    pub fn over(mock: Arc<Mock>, credential: &str) -> Over<FakeUpCloud> {
        {
            let mut e = mock.estate.lock().unwrap();
            if e.upload_base.is_empty() {
                e.upload_base = IN_PROCESS_UPLOAD_BASE.to_string();
            }
        }
        Over(FakeUpCloud { mock, credential: credential.to_string() })
    }
}

/// Where an in-process mock's upload sessions live. Not a host: nothing dials it.
pub const IN_PROCESS_UPLOAD_BASE: &str = "mock-upcloud-in-process:";

impl Exchange for FakeUpCloud {
    fn describe(&self) -> String {
        "MOCK_UPCLOUD in-process — a FAKE".to_string()
    }

    fn is_the_account(&self) -> bool {
        false
    }

    fn exchange(&self, call: Call<'_>) -> Result<Reply, String> {
        let line = call.line();
        let out = match call {
            Call::Api { method, path, body } => {
                let raw = body.map(|b| b.to_string().into_bytes()).unwrap_or_default();
                answer(&self.mock, method.as_str(), &format!("/1.3{path}"), Some(&self.credential), raw)
            }
            Call::Upload { url, file } => {
                let base = self.mock.estate.lock().unwrap().upload_base.clone();
                let Some(session) = url.strip_prefix(base.as_str()) else {
                    return Err(format!(
                        "REFUSED [upload-url-foreign] {line} does not belong to this mock (its upload base is \
                         {base:?}) — nothing is uploaded to a host the run was not aimed at"
                    ));
                };
                let bytes = std::fs::read(file).map_err(|e| format!("{}: {e}", file.display()))?;
                // The session id in the path IS the credential: no bearer, as
                // at the real upload host.
                answer(&self.mock, "PUT", session, None, bytes)
            }
        };
        match out {
            // Behaviour 2: the socket closed with nothing written. That is the
            // wire failing, never a status — and the production wire reports
            // it as an `Err` too.
            Outcome::Reset => Err(format!("{line} (the fake): transport — the connection was closed with no reply")),
            Outcome::Reply { status, body } => {
                let text = if body.is_null() { String::new() } else { body.to_string() };
                Ok(Reply { status, body: if body.is_null() { Value::Null } else { body }, text })
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::clock::Clock;
    use crate::estate::Estate;
    use crate::faults::Faults;
    use upcloud_api::{DeviceKind, NewStorage, UpCloudApi};

    fn mock() -> Arc<Mock> {
        Mock::recording(Estate::new(Clock::virtual_only(), Faults::default(), 7))
    }

    /// The face is heard like any other caller (behaviour 63), and it is never
    /// the account.
    #[test]
    fn the_face_is_heard_and_is_never_the_account() {
        let m = mock();
        let api = FakeUpCloud::over(m.clone(), "ucat_mock_face");
        assert!(!api.is_the_account() && api.describe().contains("FAKE"));
        assert!(api.account().unwrap().ok());
        let h = m.heard_from(&crate::digest::sha256_hex(b"ucat_mock_face"));
        assert_eq!((h.requests, h.account_calls), (1, 1));
    }

    /// **One world, two faces**: what the trait face writes, the HTTP router
    /// reads back — they are the same `Estate`, and the calls are recorded the
    /// same way.
    #[test]
    fn a_storage_the_face_creates_is_the_storage_the_router_reads() {
        let m = mock();
        let api = FakeUpCloud::over(m.clone(), "t");
        let r = api
            .create_storage(&NewStorage { title: "gunnar-seed", zone: "se-sto1", size_gib: 1, tier: "maxiops", labels: &[] })
            .unwrap();
        assert!(r.ok(), "{}", r.text);
        let uuid = r.body["storage"]["uuid"].as_str().unwrap().to_string();
        let (status, v) = crate::http::probe(&m, "GET", &format!("/1.3/storage/{uuid}"), Value::Null);
        assert_eq!(status, 200);
        assert_eq!(v["storage"]["title"], "gunnar-seed");
        let calls = m.calls.as_ref().unwrap().lock().unwrap();
        assert_eq!(calls[0].1, "POST");
        assert_eq!(calls[0].2, "/1.3/storage");
    }

    /// An absence answers as the API answers it, and an unknown server's
    /// attach is refused by the SAME router code terraform meets.
    #[test]
    fn a_refusal_is_the_routers_refusal() {
        let m = mock();
        let api = FakeUpCloud::over(m, "t");
        let r = api.server("00000000-0000-0000-0000-000000000000").unwrap();
        assert_eq!(r.status, 404, "{}", r.text);
        let r = api.attach_storage("00000000-0000-0000-0000-000000000000", DeviceKind::Disk, "nope", None).unwrap();
        assert!(!r.ok());
    }

    /// The upload goes to the URL the import ANSWERED, and a URL that is not
    /// this mock's is refused before a byte is read.
    #[test]
    fn an_upload_follows_the_answered_url_and_refuses_a_foreign_one() {
        let m = mock();
        let api = FakeUpCloud::over(m.clone(), "t");
        let seed = api
            .create_storage(&NewStorage { title: "seed", zone: "se-sto1", size_gib: 1, tier: "maxiops", labels: &[] })
            .unwrap();
        let uuid = seed.body["storage"]["uuid"].as_str().unwrap().to_string();
        // A new storage is `maintenance` for its create time; the import wants it
        // online. (gunnar-upcloud does NOT wait here — see the T14 report.)
        m.estate.lock().unwrap().run_to_quiet();
        let imp = api.import_direct_upload(&uuid).unwrap();
        assert!(imp.ok(), "{}", imp.text);
        let url = imp.body["storage_import"]["direct_upload_url"].as_str().unwrap().to_string();
        assert!(url.starts_with(IN_PROCESS_UPLOAD_BASE), "{url}");
        let f = std::env::temp_dir().join(format!("mock-upcloud-face-{}", std::process::id()));
        std::fs::write(&f, b"an installer medium").unwrap();
        let up = api.upload_direct(&url, &f).unwrap();
        assert!(up.ok(), "{}", up.text);
        assert_eq!(
            up.body["sha256sum"].as_str().unwrap(),
            crate::digest::sha256_hex(b"an installer medium"),
            "the digest is REAL — the ladder verifies it"
        );
        let e = api.upload_direct("https://fi-hel1.img.upcloud.com/uploader/session/x", &f).unwrap_err();
        assert!(e.contains("upload-url-foreign"), "{e}");
    }
}

#[cfg(all(test, feature = "test-inject"))]
mod injection {
    use super::*;
    use crate::clock::Clock;
    use crate::estate::Estate;
    use crate::faults::Faults;
    use upcloud_api::UpCloudApi;

    /// An injection answers the matching request ONCE, with the error it names,
    /// and then the router answers again — and it is recorded like any call.
    #[test]
    fn an_injection_answers_once_and_is_then_spent() {
        let m = Mock::recording(Estate::new(Clock::virtual_only(), Faults::default(), 1));
        m.inject(crate::http::Injection {
            method: "GET".into(),
            matches: Box::new(|path, _| path == "/1.3/account"),
            status: 409,
            code: "INJECTED".into(),
            message: "by the test".into(),
            times: 1,
        });
        let api = FakeUpCloud::over(m.clone(), "t");
        let r = api.account().unwrap();
        assert_eq!((r.status, r.error_code()), (409, "INJECTED"));
        assert!(api.account().unwrap().ok(), "spent after one");
        assert_eq!(m.calls.as_ref().unwrap().lock().unwrap().len(), 2);
    }
}