mod common;
use common::{IgnitionMock, jetty_error_html};
use ignition_core::client::{GatewayApi, ReqwestGatewayApi};
use ignition_core::config::{Credential, Secret};
use ignition_core::error::{
CoreError, GATEWAY_CLIENT_BODY_CAP_BYTES, GATEWAY_CLIENT_BODY_TRUNCATION_MARKER,
truncate_api_body,
};
const GATEWAY_INFO_PATH: &str = "/data/api/v1/gateway-info";
fn api_path(case: &str) -> String {
format!("/data/api/v1/nonexistent-{case}")
}
fn api_for(mock: &IgnitionMock) -> ReqwestGatewayApi {
ReqwestGatewayApi::for_tests(
&mock.uri(),
Some(Credential::Token(Secret::new("api:contract-token"))),
)
}
async fn api_call_error(mock: &IgnitionMock, api: &ReqwestGatewayApi, path: &str) -> CoreError {
let url = format!("{}{}", mock.uri(), path);
let parsed = url::Url::parse(&url).expect("test URL parses");
let request = reqwest::Client::new().get(&url);
api.send_and_classify_for_api(request, &parsed)
.await
.expect_err("a non-2xx must classify to Err")
}
async fn mount_exact(mock: &IgnitionMock, method: &str, path: &str, status: u16, body: &str) {
wiremock::Mock::given(wiremock::matchers::method(method))
.and(wiremock::matchers::path(path))
.respond_with(
wiremock::ResponseTemplate::new(status)
.set_body_raw(body.to_string(), "application/json"),
)
.expect(1)
.mount(&mock.server)
.await;
}
#[tokio::test]
async fn unclassified_4xx_map_to_gateway_client_error_exit_2() {
let mock = IgnitionMock::start().await;
let api = api_for(&mock);
let cases: Vec<(u16, String)> = vec![
(
400,
r#"{"error":{"code":"BAD_REQUEST","message":"the caller broke it"}}"#.to_string(),
),
(
405,
r#"{"error":{"code":"METHOD_NOT_ALLOWED"}}"#.to_string(),
),
(
409,
r#"{"error":{"code":"GENERIC_CONFLICT","detail":"not a prune, not a force"}}"#
.to_string(),
),
(
415,
r#"{"error":{"code":"UNSUPPORTED_MEDIA_TYPE"}}"#.to_string(),
),
];
for (status, body) in &cases {
let path = api_path(&format!("client-{status}"));
mount_exact(&mock, "GET", &path, *status, body).await;
let err = api_call_error(&mock, &api, &path).await;
match &err {
CoreError::GatewayClientError {
status: got_status,
endpoint,
body: got_body,
} => {
assert_eq!(*got_status, *status, "status rides verbatim: {err}");
assert_eq!(
got_body.as_str(),
body.as_str(),
"body rides VERBATIM: {err}"
);
assert_eq!(
endpoint.as_str(),
format!("{}{}", mock.uri(), path),
"endpoint = the full request URL"
);
}
other => panic!("HTTP {status} must classify GatewayClientError, got: {other}"),
}
assert_eq!(err.exit_code(), 2, "usage class: {err}");
assert_eq!(err.code(), "gateway_client_error", "slug: {err}");
}
}
#[tokio::test]
async fn classified_arms_keep_their_meanings_on_the_api_path() {
let mock = IgnitionMock::start().await;
let api = api_for(&mock);
let path = api_path("unauthorized");
mount_exact(&mock, "GET", &path, 401, r#"{"error":"Unauthorized"}"#).await;
let err = api_call_error(&mock, &api, &path).await;
assert_eq!(err.exit_code(), 5, "401 stays auth: {err}");
assert_eq!(err.code(), "auth_rejected");
let path = api_path("forbidden");
mount_exact(&mock, "GET", &path, 403, r#"{"error":"Forbidden"}"#).await;
let err = api_call_error(&mock, &api, &path).await;
assert_eq!(err.exit_code(), 5, "403 stays auth: {err}");
assert_eq!(err.code(), "auth_rejected");
let path = api_path("missing");
mount_exact(&mock, "GET", &path, 404, r#"{"message":"No route match"}"#).await;
let err = api_call_error(&mock, &api, &path).await;
assert_eq!(err.exit_code(), 6, "404 stays target state: {err}");
assert_eq!(err.code(), "not_found");
let path = api_path("restarting");
mount_exact(&mock, "GET", &path, 503, r#"{}"#).await;
let err = api_call_error(&mock, &api, &path).await;
assert_eq!(err.exit_code(), 6, "503 stays restarting: {err}");
assert_eq!(err.code(), "gateway_restarting");
let path = api_path("server-error");
mount_exact(&mock, "GET", &path, 500, r#"{"error":"boom"}"#).await;
let err = api_call_error(&mock, &api, &path).await;
assert_eq!(err.exit_code(), 1, "500 stays internal: {err}");
assert_eq!(err.code(), "internal");
}
#[tokio::test]
async fn jetty_html_400_rides_verbatim_on_the_api_path() {
let mock = IgnitionMock::start().await;
let api = api_for(&mock);
let path = api_path("html-400");
mock.html_error("GET", &path, 400).await;
let err = api_call_error(&mock, &api, &path).await;
match &err {
CoreError::GatewayClientError { body, .. } => {
assert_eq!(
body.as_str(),
jetty_error_html(400, &path),
"the RAW HTML page rides verbatim — no sniffing on the api path"
);
}
other => panic!("HTML 400 must classify GatewayClientError, got: {other}"),
}
assert_eq!(err.exit_code(), 2);
}
#[tokio::test]
async fn oversized_api_body_is_truncated_with_marker() {
let mock = IgnitionMock::start().await;
let api = api_for(&mock);
let mounted = "y".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES + 500);
let path = api_path("oversized");
mount_exact(&mock, "GET", &path, 400, &mounted).await;
let err = api_call_error(&mock, &api, &path).await;
match &err {
CoreError::GatewayClientError { body, .. } => {
assert_eq!(
body.as_str(),
truncate_api_body(&mounted),
"the carried body IS the capped construction"
);
assert!(body.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
assert!(
body.as_str().len()
<= GATEWAY_CLIENT_BODY_CAP_BYTES + GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len()
);
assert_eq!(
&body.as_str()[..GATEWAY_CLIENT_BODY_CAP_BYTES],
&mounted[..GATEWAY_CLIENT_BODY_CAP_BYTES],
"the verbatim prefix is preserved"
);
}
other => panic!("oversized 400 must classify GatewayClientError, got: {other}"),
}
}
#[tokio::test]
async fn curated_pipeline_keeps_internal_on_unclassified_400() {
let mock = IgnitionMock::start().await;
let api = api_for(&mock);
mock.status_json(
"GET",
GATEWAY_INFO_PATH,
400,
serde_json::json!({"error": {"code": "BAD_REQUEST"}}),
)
.await;
let err = api.gateway_info().await.expect_err("400 must error");
assert_eq!(err.code(), "internal", "curated 400 keeps exit-1: {err}");
assert_eq!(err.exit_code(), 1, "curated 400 stays internal: {err}");
let message = err.to_string();
assert!(
message.contains("unexpected HTTP 400"),
"the curated Internal enrichment is untouched: {message}"
);
}