Skip to main content

mock_upcloud/
face.rs

1//! **`FakeUpCloud` — the mock's in-process face on `upcloud_api::UpCloudApi`.**
2//!
3//! One world, two faces. [`crate::http`]'s socket is the face for terraform — a
4//! separate process that cannot hold a Rust trait object, which is the single
5//! legitimate reason for a wire-level fake. This is the face for Rust callers in
6//! the SAME process: every trait call becomes the same `upcloud_api::Call` the
7//! production wire sends (the path spelling lives in edda's `upcloud_api::over`,
8//! once), and that call is handed to the same door the socket parser uses —
9//! [`crate::http::answer`]: the same router, the same `Estate` methods, the same
10//! guest engine, the same behaviour-63 `heard` bookkeeping, the same recorded
11//! calls. A fault armed once applies to both faces; a VM a Rust caller starts
12//! is the VM terraform then reads.
13//!
14//! Nothing is re-implemented here, deliberately. A trait face that called
15//! `Estate` methods directly would be a SECOND interpretation of each request —
16//! the 202 on `PUT /server`, the 511 on a hotplug the guest kernel lacks, the
17//! 409 `STORAGE_ATTACHED` — and the first time the two disagreed, a Rust test
18//! would be green against a world terraform never sees.
19
20use std::sync::Arc;
21
22use serde_json::Value;
23use upcloud_api::{Call, Exchange, Over, Reply};
24
25use crate::http::{answer, Mock, Outcome};
26
27/// The in-process face. Build it with [`FakeUpCloud::over`] and hold the
28/// result as an `upcloud_api::UpCloudApi`.
29pub struct FakeUpCloud {
30    mock: Arc<Mock>,
31    /// The bearer this face presents — per run, so `/mock/heard` can tell this
32    /// caller's traffic from anybody else's (behaviour 63).
33    credential: String,
34}
35
36impl FakeUpCloud {
37    /// The trait face over `mock`, presenting `credential` as its bearer.
38    ///
39    /// If the mock has never been served on a socket, its import replies would
40    /// name a real-looking `https://<zone>.img.upcloud.com/…` upload URL; the
41    /// face gives the estate an in-process upload base instead, so a Rust
42    /// caller that FOLLOWS the URL it was answered (rather than building one)
43    /// lands back here. A served mock keeps its socket's base.
44    pub fn over(mock: Arc<Mock>, credential: &str) -> Over<FakeUpCloud> {
45        {
46            let mut e = mock.estate.lock().unwrap();
47            if e.upload_base.is_empty() {
48                e.upload_base = IN_PROCESS_UPLOAD_BASE.to_string();
49            }
50        }
51        Over(FakeUpCloud { mock, credential: credential.to_string() })
52    }
53}
54
55/// Where an in-process mock's upload sessions live. Not a host: nothing dials it.
56pub const IN_PROCESS_UPLOAD_BASE: &str = "mock-upcloud-in-process:";
57
58impl Exchange for FakeUpCloud {
59    fn describe(&self) -> String {
60        "MOCK_UPCLOUD in-process — a FAKE".to_string()
61    }
62
63    fn is_the_account(&self) -> bool {
64        false
65    }
66
67    fn exchange(&self, call: Call<'_>) -> Result<Reply, String> {
68        let line = call.line();
69        let out = match call {
70            Call::Api { method, path, body } => {
71                let raw = body.map(|b| b.to_string().into_bytes()).unwrap_or_default();
72                answer(&self.mock, method.as_str(), &format!("/1.3{path}"), Some(&self.credential), raw)
73            }
74            Call::Upload { url, file } => {
75                let base = self.mock.estate.lock().unwrap().upload_base.clone();
76                let Some(session) = url.strip_prefix(base.as_str()) else {
77                    return Err(format!(
78                        "REFUSED [upload-url-foreign] {line} does not belong to this mock (its upload base is \
79                         {base:?}) — nothing is uploaded to a host the run was not aimed at"
80                    ));
81                };
82                let bytes = std::fs::read(file).map_err(|e| format!("{}: {e}", file.display()))?;
83                // The session id in the path IS the credential: no bearer, as
84                // at the real upload host.
85                answer(&self.mock, "PUT", session, None, bytes)
86            }
87        };
88        match out {
89            // Behaviour 2: the socket closed with nothing written. That is the
90            // wire failing, never a status — and the production wire reports
91            // it as an `Err` too.
92            Outcome::Reset => Err(format!("{line} (the fake): transport — the connection was closed with no reply")),
93            Outcome::Reply { status, body } => {
94                let text = if body.is_null() { String::new() } else { body.to_string() };
95                Ok(Reply { status, body: if body.is_null() { Value::Null } else { body }, text })
96            }
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::clock::Clock;
105    use crate::estate::Estate;
106    use crate::faults::Faults;
107    use upcloud_api::{DeviceKind, NewStorage, UpCloudApi};
108
109    fn mock() -> Arc<Mock> {
110        Mock::recording(Estate::new(Clock::virtual_only(), Faults::default(), 7))
111    }
112
113    /// The face is heard like any other caller (behaviour 63), and it is never
114    /// the account.
115    #[test]
116    fn the_face_is_heard_and_is_never_the_account() {
117        let m = mock();
118        let api = FakeUpCloud::over(m.clone(), "ucat_mock_face");
119        assert!(!api.is_the_account() && api.describe().contains("FAKE"));
120        assert!(api.account().unwrap().ok());
121        let h = m.heard_from(&crate::digest::sha256_hex(b"ucat_mock_face"));
122        assert_eq!((h.requests, h.account_calls), (1, 1));
123    }
124
125    /// **One world, two faces**: what the trait face writes, the HTTP router
126    /// reads back — they are the same `Estate`, and the calls are recorded the
127    /// same way.
128    #[test]
129    fn a_storage_the_face_creates_is_the_storage_the_router_reads() {
130        let m = mock();
131        let api = FakeUpCloud::over(m.clone(), "t");
132        let r = api
133            .create_storage(&NewStorage { title: "gunnar-seed", zone: "se-sto1", size_gib: 1, tier: "maxiops", labels: &[] })
134            .unwrap();
135        assert!(r.ok(), "{}", r.text);
136        let uuid = r.body["storage"]["uuid"].as_str().unwrap().to_string();
137        let (status, v) = crate::http::probe(&m, "GET", &format!("/1.3/storage/{uuid}"), Value::Null);
138        assert_eq!(status, 200);
139        assert_eq!(v["storage"]["title"], "gunnar-seed");
140        let calls = m.calls.as_ref().unwrap().lock().unwrap();
141        assert_eq!(calls[0].1, "POST");
142        assert_eq!(calls[0].2, "/1.3/storage");
143    }
144
145    /// An absence answers as the API answers it, and an unknown server's
146    /// attach is refused by the SAME router code terraform meets.
147    #[test]
148    fn a_refusal_is_the_routers_refusal() {
149        let m = mock();
150        let api = FakeUpCloud::over(m, "t");
151        let r = api.server("00000000-0000-0000-0000-000000000000").unwrap();
152        assert_eq!(r.status, 404, "{}", r.text);
153        let r = api.attach_storage("00000000-0000-0000-0000-000000000000", DeviceKind::Disk, "nope", None).unwrap();
154        assert!(!r.ok());
155    }
156
157    /// The upload goes to the URL the import ANSWERED, and a URL that is not
158    /// this mock's is refused before a byte is read.
159    #[test]
160    fn an_upload_follows_the_answered_url_and_refuses_a_foreign_one() {
161        let m = mock();
162        let api = FakeUpCloud::over(m.clone(), "t");
163        let seed = api
164            .create_storage(&NewStorage { title: "seed", zone: "se-sto1", size_gib: 1, tier: "maxiops", labels: &[] })
165            .unwrap();
166        let uuid = seed.body["storage"]["uuid"].as_str().unwrap().to_string();
167        // A new storage is `maintenance` for its create time; the import wants it
168        // online. (gunnar-upcloud does NOT wait here — see the T14 report.)
169        m.estate.lock().unwrap().run_to_quiet();
170        let imp = api.import_direct_upload(&uuid).unwrap();
171        assert!(imp.ok(), "{}", imp.text);
172        let url = imp.body["storage_import"]["direct_upload_url"].as_str().unwrap().to_string();
173        assert!(url.starts_with(IN_PROCESS_UPLOAD_BASE), "{url}");
174        let f = std::env::temp_dir().join(format!("mock-upcloud-face-{}", std::process::id()));
175        std::fs::write(&f, b"an installer medium").unwrap();
176        let up = api.upload_direct(&url, &f).unwrap();
177        assert!(up.ok(), "{}", up.text);
178        assert_eq!(
179            up.body["sha256sum"].as_str().unwrap(),
180            crate::digest::sha256_hex(b"an installer medium"),
181            "the digest is REAL — the ladder verifies it"
182        );
183        let e = api.upload_direct("https://fi-hel1.img.upcloud.com/uploader/session/x", &f).unwrap_err();
184        assert!(e.contains("upload-url-foreign"), "{e}");
185    }
186}
187
188#[cfg(all(test, feature = "test-inject"))]
189mod injection {
190    use super::*;
191    use crate::clock::Clock;
192    use crate::estate::Estate;
193    use crate::faults::Faults;
194    use upcloud_api::UpCloudApi;
195
196    /// An injection answers the matching request ONCE, with the error it names,
197    /// and then the router answers again — and it is recorded like any call.
198    #[test]
199    fn an_injection_answers_once_and_is_then_spent() {
200        let m = Mock::recording(Estate::new(Clock::virtual_only(), Faults::default(), 1));
201        m.inject(crate::http::Injection {
202            method: "GET".into(),
203            matches: Box::new(|path, _| path == "/1.3/account"),
204            status: 409,
205            code: "INJECTED".into(),
206            message: "by the test".into(),
207            times: 1,
208        });
209        let api = FakeUpCloud::over(m.clone(), "t");
210        let r = api.account().unwrap();
211        assert_eq!((r.status, r.error_code()), (409, "INJECTED"));
212        assert!(api.account().unwrap().ok(), "spent after one");
213        assert_eq!(m.calls.as_ref().unwrap().lock().unwrap().len(), 2);
214    }
215}