Files
cli/sunbeam-sdk/tests/test_client.rs
Sienna Meridian Satterwhite f06a167496 feat: BuildKit client + integration test suite (651 tests)
BuildKitClient CLI wrapper for buildctl.
Docker compose stack (9 services) for integration testing.
Comprehensive test suite: wiremock tests for Matrix/La Suite/S3/client,
integration tests for Kratos/Hydra/Gitea/OpenSearch/Prometheus/Loki/
Grafana/LiveKit.

Bump: sunbeam-sdk v0.12.0
2026-03-21 20:35:59 +00:00

592 lines
20 KiB
Rust

#![cfg(feature = "integration")]
use sunbeam_sdk::client::{AuthMethod, HttpTransport, SunbeamClient};
use sunbeam_sdk::config::Context;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use reqwest::Method;
// ---------------------------------------------------------------------------
// 1. json() success — 200 + valid JSON
// ---------------------------------------------------------------------------
#[tokio::test]
async fn json_success() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/things"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"id": 42, "name": "widget"})),
)
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let val: serde_json::Value = t
.json(Method::GET, "/api/things", Option::<&()>::None, "fetch things")
.await
.unwrap();
assert_eq!(val["id"], 42);
assert_eq!(val["name"], "widget");
}
// ---------------------------------------------------------------------------
// 2. json() HTTP error — 500 returns SunbeamError::Network with ctx in msg
// ---------------------------------------------------------------------------
#[tokio::test]
async fn json_http_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/fail"))
.respond_with(ResponseTemplate::new(500).set_body_string("internal oops"))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let err = t
.json::<serde_json::Value>(Method::GET, "/fail", Option::<&()>::None, "load stuff")
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("load stuff"), "error should contain ctx: {msg}");
assert!(msg.contains("500"), "error should contain status: {msg}");
}
// ---------------------------------------------------------------------------
// 3. json() parse error — 200 + invalid JSON
// ---------------------------------------------------------------------------
#[tokio::test]
async fn json_parse_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/bad-json"))
.respond_with(ResponseTemplate::new(200).set_body_string("not json {{{"))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let err = t
.json::<serde_json::Value>(Method::GET, "/bad-json", Option::<&()>::None, "parse ctx")
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("parse ctx"),
"parse error should contain ctx: {msg}"
);
}
// ---------------------------------------------------------------------------
// 4. json_opt() success — 200 + JSON → Some(T)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn json_opt_success() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/item"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"found": true})),
)
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let val: Option<serde_json::Value> = t
.json_opt(Method::GET, "/item", Option::<&()>::None, "get item")
.await
.unwrap();
assert!(val.is_some());
assert_eq!(val.unwrap()["found"], true);
}
// ---------------------------------------------------------------------------
// 5. json_opt() 404 → None
// ---------------------------------------------------------------------------
#[tokio::test]
async fn json_opt_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/missing"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let val: Option<serde_json::Value> = t
.json_opt(Method::GET, "/missing", Option::<&()>::None, "lookup")
.await
.unwrap();
assert!(val.is_none());
}
// ---------------------------------------------------------------------------
// 6. json_opt() server error — 500 → Err
// ---------------------------------------------------------------------------
#[tokio::test]
async fn json_opt_server_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/boom"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let err = t
.json_opt::<serde_json::Value>(Method::GET, "/boom", Option::<&()>::None, "opt-fail")
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("opt-fail"), "error should contain ctx: {msg}");
assert!(msg.contains("500"), "error should contain status: {msg}");
}
// ---------------------------------------------------------------------------
// 7. send() success — 200 → Ok(())
// ---------------------------------------------------------------------------
#[tokio::test]
async fn send_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/action"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
t.send(Method::POST, "/action", Option::<&()>::None, "do action")
.await
.unwrap();
}
// ---------------------------------------------------------------------------
// 8. send() error — 403 → Err
// ---------------------------------------------------------------------------
#[tokio::test]
async fn send_forbidden() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/protected"))
.respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let err = t
.send(Method::DELETE, "/protected", Option::<&()>::None, "delete thing")
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("delete thing"), "error should contain ctx: {msg}");
assert!(msg.contains("403"), "error should contain status: {msg}");
}
// ---------------------------------------------------------------------------
// 9. bytes() success — 200 + raw bytes
// ---------------------------------------------------------------------------
#[tokio::test]
async fn bytes_success() {
let payload = b"binary-data-here";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/download"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.to_vec()))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let data = t.bytes(Method::GET, "/download", "fetch binary").await.unwrap();
assert_eq!(data.as_ref(), payload);
}
// ---------------------------------------------------------------------------
// 10. bytes() error — 500
// ---------------------------------------------------------------------------
#[tokio::test]
async fn bytes_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/download-fail"))
.respond_with(ResponseTemplate::new(500).set_body_string("nope"))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let err = t
.bytes(Method::GET, "/download-fail", "get bytes")
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("get bytes"), "error should contain ctx: {msg}");
assert!(msg.contains("500"), "error should contain status: {msg}");
}
// ---------------------------------------------------------------------------
// 11. request() with Bearer auth — verify Authorization header
// ---------------------------------------------------------------------------
#[tokio::test]
async fn request_bearer_auth() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/auth-check"))
.and(header("Authorization", "Bearer my-secret-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::Bearer("my-secret-token".into()));
let val: serde_json::Value = t
.json(Method::GET, "/auth-check", Option::<&()>::None, "bearer test")
.await
.unwrap();
assert_eq!(val["ok"], true);
}
// ---------------------------------------------------------------------------
// 12. request() with Header auth — verify custom header
// ---------------------------------------------------------------------------
#[tokio::test]
async fn request_header_auth() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault"))
.and(header("X-Vault-Token", "hvs.root-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"sealed": false})))
.mount(&server)
.await;
let t = HttpTransport::new(
&server.uri(),
AuthMethod::Header {
name: "X-Vault-Token",
value: "hvs.root-token".into(),
},
);
let val: serde_json::Value = t
.json(Method::GET, "/vault", Option::<&()>::None, "header auth")
.await
.unwrap();
assert_eq!(val["sealed"], false);
}
// ---------------------------------------------------------------------------
// 13. request() with Token auth — verify "token {pat}" format
// ---------------------------------------------------------------------------
#[tokio::test]
async fn request_token_auth() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/gitea"))
.and(header("Authorization", "token pat-abc-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"user": "ci"})))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::Token("pat-abc-123".into()));
let val: serde_json::Value = t
.json(Method::GET, "/gitea", Option::<&()>::None, "token auth")
.await
.unwrap();
assert_eq!(val["user"], "ci");
}
// ---------------------------------------------------------------------------
// 14. request() with None auth — no Authorization header
// ---------------------------------------------------------------------------
#[tokio::test]
async fn request_no_auth() {
let server = MockServer::start().await;
// The mock only matches when there is NO Authorization header.
// wiremock does not have a "header absent" matcher, so we just verify
// the request succeeds (no auth header is fine) and inspect received
// requests afterward.
Mock::given(method("GET"))
.and(path("/public"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"public": true})))
.expect(1)
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let val: serde_json::Value = t
.json(Method::GET, "/public", Option::<&()>::None, "no auth")
.await
.unwrap();
assert_eq!(val["public"], true);
// Verify no Authorization header was sent.
let reqs = server.received_requests().await.unwrap();
assert_eq!(reqs.len(), 1);
assert!(
!reqs[0].headers.iter().any(|(k, _)| k == "authorization"),
"Authorization header should not be present for AuthMethod::None"
);
}
// ---------------------------------------------------------------------------
// 15. set_auth() — change auth, verify next request uses new auth
// ---------------------------------------------------------------------------
#[tokio::test]
async fn set_auth_changes_header() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/check"))
.and(header("Authorization", "Bearer new-tok"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
.mount(&server)
.await;
let mut t = HttpTransport::new(&server.uri(), AuthMethod::None);
t.set_auth(AuthMethod::Bearer("new-tok".into()));
let val: serde_json::Value = t
.json(Method::GET, "/check", Option::<&()>::None, "after set_auth")
.await
.unwrap();
assert_eq!(val["ok"], true);
}
// ---------------------------------------------------------------------------
// 16. URL construction — leading slash handling
// ---------------------------------------------------------------------------
#[tokio::test]
async fn url_construction_with_leading_slash() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/a/b"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"p": "ok"})))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
// With leading slash
let val: serde_json::Value = t
.json(Method::GET, "/a/b", Option::<&()>::None, "slash")
.await
.unwrap();
assert_eq!(val["p"], "ok");
}
#[tokio::test]
async fn url_construction_without_leading_slash() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/x/y"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"q": 1})))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
// Without leading slash
let val: serde_json::Value = t
.json(Method::GET, "x/y", Option::<&()>::None, "no-slash")
.await
.unwrap();
assert_eq!(val["q"], 1);
}
#[tokio::test]
async fn url_construction_trailing_slash_stripped() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/z"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"z": true})))
.mount(&server)
.await;
// Base URL with trailing slash
let t = HttpTransport::new(&format!("{}/", server.uri()), AuthMethod::None);
let val: serde_json::Value = t
.json(Method::GET, "/z", Option::<&()>::None, "trailing")
.await
.unwrap();
assert_eq!(val["z"], true);
}
// ---------------------------------------------------------------------------
// 17. SunbeamClient::from_context() — domain, context accessors
// ---------------------------------------------------------------------------
#[test]
fn sunbeam_client_from_context() {
let ctx = Context {
domain: "test.sunbeam.dev".to_string(),
kube_context: "k3s-test".to_string(),
ssh_host: "root@10.0.0.1".to_string(),
infra_dir: "/opt/infra".to_string(),
acme_email: "ops@test.dev".to_string(),
};
let client = SunbeamClient::from_context(&ctx);
assert_eq!(client.domain(), "test.sunbeam.dev");
assert_eq!(client.context().domain, "test.sunbeam.dev");
assert_eq!(client.context().kube_context, "k3s-test");
assert_eq!(client.context().ssh_host, "root@10.0.0.1");
assert_eq!(client.context().infra_dir, "/opt/infra");
assert_eq!(client.context().acme_email, "ops@test.dev");
}
// ---------------------------------------------------------------------------
// Extra: json() with a request body (covers the Some(b) branch)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn json_with_request_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/create"))
.and(header("content-type", "application/json"))
.respond_with(
ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 99})),
)
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let body = serde_json::json!({"name": "new-thing"});
let val: serde_json::Value = t
.json(Method::POST, "/create", Some(&body), "create thing")
.await
.unwrap();
assert_eq!(val["id"], 99);
}
// ---------------------------------------------------------------------------
// Extra: json_opt() with a request body
// ---------------------------------------------------------------------------
#[tokio::test]
async fn json_opt_with_request_body() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/update"))
.and(header("content-type", "application/json"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"updated": true})),
)
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let body = serde_json::json!({"field": "value"});
let val: Option<serde_json::Value> = t
.json_opt(Method::PUT, "/update", Some(&body), "update thing")
.await
.unwrap();
assert!(val.is_some());
assert_eq!(val.unwrap()["updated"], true);
}
// ---------------------------------------------------------------------------
// Extra: send() with a request body
// ---------------------------------------------------------------------------
#[tokio::test]
async fn send_with_request_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/submit"))
.and(header("content-type", "application/json"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let body = serde_json::json!({"payload": 123});
t.send(Method::POST, "/submit", Some(&body), "submit data")
.await
.unwrap();
}
// ---------------------------------------------------------------------------
// Extra: json_opt() parse error — 200 + invalid JSON → Err
// ---------------------------------------------------------------------------
#[tokio::test]
async fn json_opt_parse_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/bad-opt"))
.respond_with(ResponseTemplate::new(200).set_body_string("<<<not json>>>"))
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let err = t
.json_opt::<serde_json::Value>(Method::GET, "/bad-opt", Option::<&()>::None, "opt-parse")
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("opt-parse"),
"parse error should contain ctx: {msg}"
);
}
// ---------------------------------------------------------------------------
// Extra: error body text appears in Network error messages
// ---------------------------------------------------------------------------
#[tokio::test]
async fn error_body_text_in_message() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/err-body"))
.respond_with(
ResponseTemplate::new(422).set_body_string("validation failed: email required"),
)
.mount(&server)
.await;
let t = HttpTransport::new(&server.uri(), AuthMethod::None);
let err = t
.json::<serde_json::Value>(Method::GET, "/err-body", Option::<&()>::None, "validate")
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("422"), "should contain status: {msg}");
assert!(
msg.contains("validation failed"),
"should contain body text: {msg}"
);
}