1use std::sync::Arc;
21
22use serde_json::Value;
23use upcloud_api::{Call, Exchange, Over, Reply};
24
25use crate::http::{answer, Mock, Outcome};
26
27pub struct FakeUpCloud {
30 mock: Arc<Mock>,
31 credential: String,
34}
35
36impl FakeUpCloud {
37 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
55pub 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 answer(&self.mock, "PUT", session, None, bytes)
86 }
87 };
88 match out {
89 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 #[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 #[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 #[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 #[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 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 #[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}