1use crate::error::CoreError;
35
36const REFUSED_AUTH_HEADERS: [&str; 3] = ["authorization", "x-ignition-api-token", "cookie"];
40
41#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ApiCallRequest {
47 pub method: String,
49 pub path: String,
51 pub body: Option<String>,
53 pub headers: Vec<(String, String)>,
55 pub query: Vec<(String, String)>,
57}
58
59#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct ApiCallData {
64 pub status: u16,
66 pub data: Box<serde_json::value::RawValue>,
68}
69
70pub fn refuse_auth_headers(headers: &[(String, String)]) -> Result<(), CoreError> {
76 for (name, _) in headers {
77 let normalized = name.trim().to_ascii_lowercase();
78 if REFUSED_AUTH_HEADERS.contains(&normalized.as_str()) {
79 return Err(CoreError::InvalidInput {
80 reason: format!(
81 "header {name:?} is auth-pattern and refused — credentials come \
82 from the profile (ign applies X-Ignition-API-Token itself); \
83 pass only non-auth headers"
84 ),
85 });
86 }
87 }
88 Ok(())
89}
90
91pub fn validate_path(path: &str) -> Result<(), CoreError> {
103 if path.starts_with("//")
109 || url::Url::parse(path).is_ok_and(|parsed| parsed.host_str().is_some())
110 {
111 return Err(CoreError::InvalidInput {
112 reason: format!(
113 "--path must be a path, not a URL ({path:?} carries a host) — the \
114 request always goes to the profile's gateway"
115 ),
116 });
117 }
118 if !path.starts_with('/') {
119 return Err(CoreError::InvalidInput {
120 reason: format!(
121 "--path must start with '/' (it joins onto the profile's gateway URL): {path:?}"
122 ),
123 });
124 }
125 if path.contains('?') {
126 return Err(CoreError::InvalidInput {
127 reason: format!(
128 "--path must not embed a query string ({path:?}) — use the repeatable \
129 --query k=v flag (the ONE query mechanism)"
130 ),
131 });
132 }
133 Ok(())
134}
135
136#[cfg(test)]
137mod tests {
138 use super::{ApiCallRequest, REFUSED_AUTH_HEADERS, refuse_auth_headers, validate_path};
139 use crate::client::GatewayApi;
140 use crate::client::ReqwestGatewayApi;
141 use crate::config::{Credential, Secret};
142 use crate::error::CoreError;
143
144 #[test]
147 fn refusal_matrix_covers_case_and_whitespace_variants() {
148 let canonical = [("Authorization".to_string(), "Bearer x".to_string())];
149 let case_variant = [("AUTHORIZATION".to_string(), "Bearer x".to_string())];
150 let mixed_case = [("authorization".to_string(), "Bearer x".to_string())];
151 let token = [("x-ignition-api-token".to_string(), "name:key".to_string())];
152 let token_canonical = [("X-Ignition-API-Token".to_string(), "name:key".to_string())];
153 let cookie = [("Cookie".to_string(), "session=1".to_string())];
154 let whitespace = [(" Authorization ".to_string(), "Bearer x".to_string())];
155
156 for headers in [
157 &canonical,
158 &case_variant,
159 &mixed_case,
160 &token,
161 &token_canonical,
162 &cookie,
163 &whitespace,
164 ] {
165 let err = refuse_auth_headers(headers).expect_err("auth-pattern header refuses");
166 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
167 assert_eq!(err.code(), "invalid_input");
168 assert_eq!(err.exit_code(), 2, "usage class");
169 let text = err.to_string();
170 assert!(
171 text.contains("profile"),
172 "the refusal names the profile-auth rule: {text}"
173 );
174 }
175
176 refuse_auth_headers(&[("X-Custom-Thing".to_string(), "v".to_string())])
178 .expect("non-auth header passes");
179 refuse_auth_headers(&[]).expect("no headers passes");
180 }
181
182 #[test]
185 fn refused_set_is_exactly_the_documented_three() {
186 assert_eq!(REFUSED_AUTH_HEADERS.len(), 3);
187 assert!(REFUSED_AUTH_HEADERS.contains(&"authorization"));
188 assert!(REFUSED_AUTH_HEADERS.contains(&"x-ignition-api-token"));
189 assert!(REFUSED_AUTH_HEADERS.contains(&"cookie"));
190 }
191
192 #[test]
195 fn path_matrix_refuses_bad_shapes_and_accepts_clean_paths() {
196 let missing_slash = validate_path("data/api/v1/x").expect_err("missing slash refuses");
198 assert!(missing_slash.to_string().contains("'/'"), "{missing_slash}");
199
200 let absolute = validate_path("http://other/x").expect_err("absolute URL refuses");
201 assert!(absolute.to_string().contains("host"), "{absolute}");
202
203 let protocol_relative = validate_path("//host/x").expect_err("//host refuses");
204 assert!(
205 protocol_relative.to_string().contains("host"),
206 "{protocol_relative}"
207 );
208
209 let query_embedded = validate_path("/data/x?embed=1").expect_err("embedded ? refuses");
210 assert!(
211 query_embedded.to_string().contains("--query"),
212 "the ? refusal names the ONE query mechanism: {query_embedded}"
213 );
214
215 validate_path("/data/x").expect("clean single-segment path passes");
217 validate_path("/data/api/v1/gateway-info").expect("multi-segment path passes");
218 validate_path("/").expect("root path passes");
219 }
220
221 fn token_client(base: &str) -> ReqwestGatewayApi {
224 ReqwestGatewayApi::for_tests(base, Some(Credential::Token(Secret::new("name:key"))))
225 }
226
227 #[tokio::test]
231 async fn success_body_rides_verbatim_with_status() {
232 let server = wiremock::MockServer::start().await;
233 wiremock::Mock::given(wiremock::matchers::method("GET"))
234 .and(wiremock::matchers::path("/data/api/v1/x"))
235 .respond_with(
236 wiremock::ResponseTemplate::new(200)
237 .set_body_string(r#"{"zz_last": 1, "alpha_first": {"b": 2, "a": 1}}"#),
238 )
239 .expect(1)
240 .mount(&server)
241 .await;
242
243 let call = ApiCallRequest {
244 method: "GET".to_string(),
245 path: "/data/api/v1/x".to_string(),
246 body: None,
247 headers: vec![],
248 query: vec![],
249 };
250 let data = token_client(&server.uri())
251 .api_call(&call)
252 .await
253 .expect("2xx answers");
254 assert_eq!(data.status, 200);
255 assert_eq!(
256 data.data.get(),
257 r#"{"zz_last": 1, "alpha_first": {"b": 2, "a": 1}}"#
258 );
259 }
260
261 #[tokio::test]
265 async fn request_rides_auth_query_and_get_body() {
266 let server = wiremock::MockServer::start().await;
267 wiremock::Mock::given(wiremock::matchers::method("GET"))
268 .and(wiremock::matchers::path("/data/api/v1/x"))
269 .and(wiremock::matchers::query_param("a", "1"))
270 .and(wiremock::matchers::query_param("b", "2"))
271 .and(wiremock::matchers::header(
272 "x-ignition-api-token",
273 "name:key",
274 ))
275 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("{}"))
276 .expect(1)
277 .mount(&server)
278 .await;
279
280 let call = ApiCallRequest {
281 method: "GET".to_string(),
282 path: "/data/api/v1/x".to_string(),
283 body: Some(r#"{"x":1}"#.to_string()),
284 headers: vec![("X-Custom".to_string(), "v".to_string())],
285 query: vec![
286 ("a".to_string(), "1".to_string()),
287 ("b".to_string(), "2".to_string()),
288 ],
289 };
290 token_client(&server.uri())
291 .api_call(&call)
292 .await
293 .expect("mock answers");
294
295 let requests = server.received_requests().await.expect("requests recorded");
296 assert_eq!(requests.len(), 1);
297 let body = String::from_utf8_lossy(&requests[0].body);
298 assert_eq!(body, r#"{"x":1}"#, "the GET carries the raw body verbatim");
299 let custom = requests[0]
300 .headers
301 .iter()
302 .find(|(name, _)| name.as_str() == "x-custom")
303 .map(|(_, value)| value.to_str().expect("ascii").to_string());
304 assert_eq!(custom.as_deref(), Some("v"), "the user header rode along");
305 }
306
307 #[tokio::test]
311 async fn non_json_2xx_body_refuses_internal() {
312 let server = wiremock::MockServer::start().await;
313 wiremock::Mock::given(wiremock::matchers::method("GET"))
314 .and(wiremock::matchers::path("/data/api/v1/x"))
315 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("this is not json"))
316 .expect(1)
317 .mount(&server)
318 .await;
319
320 let call = ApiCallRequest {
321 method: "GET".to_string(),
322 path: "/data/api/v1/x".to_string(),
323 body: None,
324 headers: vec![],
325 query: vec![],
326 };
327 let err = token_client(&server.uri())
328 .api_call(&call)
329 .await
330 .expect_err("non-JSON 2xx refuses");
331 assert!(matches!(err, CoreError::Internal(_)), "{err}");
332 assert_eq!(err.exit_code(), 1);
333 let text = err.to_string();
334 assert!(
335 text.contains("non-JSON body"),
336 "the message explains the contract: {text}"
337 );
338 }
339
340 #[tokio::test]
345 async fn invalid_method_refuses_invalid_input() {
346 let server = wiremock::MockServer::start().await;
347 for method in ["NOT A VERB", "GE\tT", ""] {
348 let call = ApiCallRequest {
349 method: method.to_string(),
350 path: "/data/x".to_string(),
351 body: None,
352 headers: vec![],
353 query: vec![],
354 };
355 let err = token_client(&server.uri())
356 .api_call(&call)
357 .await
358 .expect_err("invalid verb refuses");
359 assert!(
360 matches!(err, CoreError::InvalidInput { .. }),
361 "{method:?} refuses invalid_input: {err}"
362 );
363 }
364 let requests = server.received_requests().await.expect("requests recorded");
366 assert!(
367 requests.is_empty(),
368 "no request may hit the wire: {requests:?}"
369 );
370 }
371}