Files
cli/sunbeam-sdk/tests/integration.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

529 lines
17 KiB
Rust

//! Integration tests for sunbeam-sdk service clients.
//!
//! Requires the test stack running:
//! docker compose -f sunbeam-sdk/tests/docker-compose.yml up -d
//!
//! Run with:
//! cargo test -p sunbeam-sdk --features integration --test integration
#![cfg(feature = "integration")]
use sunbeam_sdk::client::{AuthMethod, ServiceClient};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Poll a URL until it returns 200, or panic after `timeout`.
async fn wait_for_healthy(url: &str, timeout: std::time::Duration) {
let client = reqwest::Client::new();
let deadline = tokio::time::Instant::now() + timeout;
loop {
if tokio::time::Instant::now() > deadline {
panic!("Service at {url} did not become healthy within {timeout:?}");
}
if let Ok(resp) = client.get(url).send().await {
if resp.status().is_success() {
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
// ---------------------------------------------------------------------------
// Kratos
// ---------------------------------------------------------------------------
mod kratos {
use super::*;
use sunbeam_sdk::identity::KratosClient;
fn client() -> KratosClient {
KratosClient::from_parts("http://localhost:4434".into(), AuthMethod::None)
}
#[tokio::test]
async fn health() {
wait_for_healthy("http://localhost:4434/health/alive", TIMEOUT).await;
let c = client();
let status = c.alive().await.unwrap();
assert_eq!(status.status, "ok");
}
#[tokio::test]
async fn identity_crud() {
wait_for_healthy("http://localhost:4434/health/alive", TIMEOUT).await;
let c = client();
// Create
let body = sunbeam_sdk::identity::types::CreateIdentityBody {
schema_id: "default".into(),
traits: serde_json::json!({"email": "integration-test@example.com"}),
state: Some("active".into()),
metadata_public: None,
metadata_admin: None,
credentials: None,
verifiable_addresses: None,
recovery_addresses: None,
};
let identity = c.create_identity(&body).await.unwrap();
assert!(!identity.id.is_empty());
let id = identity.id.clone();
// Get
let fetched = c.get_identity(&id).await.unwrap();
assert_eq!(fetched.id, id);
// List
let list = c.list_identities(None, None).await.unwrap();
assert!(list.iter().any(|i| i.id == id));
// Update
let update = sunbeam_sdk::identity::types::UpdateIdentityBody {
schema_id: "default".into(),
traits: serde_json::json!({"email": "updated@example.com"}),
state: "active".into(),
metadata_public: None,
metadata_admin: None,
credentials: None,
};
let updated = c.update_identity(&id, &update).await.unwrap();
assert_eq!(updated.traits["email"], "updated@example.com");
// Delete
c.delete_identity(&id).await.unwrap();
let list = c.list_identities(None, None).await.unwrap();
assert!(!list.iter().any(|i| i.id == id));
}
#[tokio::test]
async fn schemas() {
wait_for_healthy("http://localhost:4434/health/alive", TIMEOUT).await;
let c = client();
let schemas = c.list_schemas().await.unwrap();
assert!(!schemas.is_empty());
}
}
// ---------------------------------------------------------------------------
// Hydra
// ---------------------------------------------------------------------------
mod hydra {
use super::*;
use sunbeam_sdk::auth::hydra::HydraClient;
use sunbeam_sdk::auth::hydra::types::OAuth2Client;
fn client() -> HydraClient {
HydraClient::from_parts("http://localhost:4445".into(), AuthMethod::None)
}
#[tokio::test]
async fn oauth2_client_crud() {
wait_for_healthy("http://localhost:4445/health/alive", TIMEOUT).await;
let c = client();
// Create
let body = OAuth2Client {
client_name: Some("test-client".into()),
grant_types: Some(vec!["authorization_code".into()]),
redirect_uris: Some(vec!["http://localhost:9876/callback".into()]),
scope: Some("openid email".into()),
token_endpoint_auth_method: Some("none".into()),
..Default::default()
};
let created = c.create_client(&body).await.unwrap();
let cid = created.client_id.unwrap();
assert!(!cid.is_empty());
// Get
let fetched = c.get_client(&cid).await.unwrap();
assert_eq!(fetched.client_name, Some("test-client".into()));
// List
let list = c.list_clients(None, None).await.unwrap();
assert!(list.iter().any(|cl| cl.client_id.as_deref() == Some(&cid)));
// Update
let mut updated_body = fetched.clone();
updated_body.client_name = Some("renamed-client".into());
let updated = c.update_client(&cid, &updated_body).await.unwrap();
assert_eq!(updated.client_name, Some("renamed-client".into()));
// Delete
c.delete_client(&cid).await.unwrap();
let list = c.list_clients(None, None).await.unwrap();
assert!(!list.iter().any(|cl| cl.client_id.as_deref() == Some(&cid)));
}
#[tokio::test]
async fn token_introspect_inactive() {
wait_for_healthy("http://localhost:4445/health/alive", TIMEOUT).await;
let c = client();
let result = c.introspect_token("bogus-token").await.unwrap();
assert!(!result.active);
}
}
// ---------------------------------------------------------------------------
// Gitea
// ---------------------------------------------------------------------------
mod gitea {
use super::*;
use sunbeam_sdk::gitea::GiteaClient;
const ADMIN_USER: &str = "testadmin";
const ADMIN_PASS: &str = "testpass123";
const ADMIN_EMAIL: &str = "admin@test.local";
/// Bootstrap admin user + PAT. Returns the PAT string.
async fn setup_gitea() -> String {
wait_for_healthy("http://localhost:3000/api/v1/version", TIMEOUT).await;
let http = reqwest::Client::new();
// Register user via public API (DISABLE_REGISTRATION=false)
let _ = http
.post("http://localhost:3000/user/sign_up")
.form(&[
("user_name", ADMIN_USER),
("password", ADMIN_PASS),
("retype", ADMIN_PASS),
("email", ADMIN_EMAIL),
])
.send()
.await;
// Create PAT using basic auth
let resp = http
.post(format!(
"http://localhost:3000/api/v1/users/{ADMIN_USER}/tokens"
))
.basic_auth(ADMIN_USER, Some(ADMIN_PASS))
.json(&serde_json::json!({
"name": format!("test-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis()),
"scopes": ["all"]
}))
.send()
.await
.unwrap();
if !resp.status().is_success() {
panic!("PAT creation failed: {}", resp.text().await.unwrap_or_default());
}
let body: serde_json::Value = resp.json().await.unwrap();
body["sha1"]
.as_str()
.or_else(|| body["token"].as_str())
.expect("PAT response missing sha1/token field")
.to_string()
}
#[tokio::test]
async fn repo_crud() {
let pat = setup_gitea().await;
let c = GiteaClient::from_parts(
"http://localhost:3000/api/v1".into(),
AuthMethod::Token(pat),
);
// Authenticated user
let me = c.get_authenticated_user().await.unwrap();
assert_eq!(me.login, ADMIN_USER);
// Create repo
let body = sunbeam_sdk::gitea::types::CreateRepoBody {
name: "integration-test".into(),
description: Some("test repo".into()),
auto_init: Some(true),
..Default::default()
};
let repo = c.create_user_repo(&body).await.unwrap();
assert_eq!(repo.name, "integration-test");
// Get repo
let fetched = c.get_repo(ADMIN_USER, "integration-test").await.unwrap();
assert_eq!(fetched.full_name, format!("{ADMIN_USER}/integration-test"));
// Search repos
let results = c.search_repos("integration", None).await.unwrap();
assert!(!results.data.is_empty());
// Delete repo
c.delete_repo(ADMIN_USER, "integration-test").await.unwrap();
}
}
// ---------------------------------------------------------------------------
// OpenSearch
// ---------------------------------------------------------------------------
mod opensearch {
use super::*;
use sunbeam_sdk::search::OpenSearchClient;
fn client() -> OpenSearchClient {
OpenSearchClient::from_parts("http://localhost:9200".into(), AuthMethod::None)
}
#[tokio::test]
async fn cluster_health() {
wait_for_healthy("http://localhost:9200/_cluster/health", TIMEOUT).await;
let c = client();
let health = c.cluster_health().await.unwrap();
assert!(!health.cluster_name.is_empty());
}
#[tokio::test]
async fn document_crud() {
wait_for_healthy("http://localhost:9200/_cluster/health", TIMEOUT).await;
let c = client();
let idx = "integration-test";
// Create index
let _ = c
.create_index(idx, &serde_json::json!({"settings": {"number_of_shards": 1, "number_of_replicas": 0}}))
.await
.unwrap();
// Index a document
let doc = serde_json::json!({"title": "Hello", "body": "World"});
let resp = c.index_doc(idx, "doc-1", &doc).await.unwrap();
assert_eq!(resp.result.as_deref(), Some("created"));
// Refresh to make searchable (use a raw reqwest call)
let _ = reqwest::Client::new()
.post(format!("http://localhost:9200/{idx}/_refresh"))
.send()
.await;
// Get document
let got = c.get_doc(idx, "doc-1").await.unwrap();
assert_eq!(got.source.as_ref().unwrap()["title"], "Hello");
// Search
let query = serde_json::json!({"query": {"match_all": {}}});
let results = c.search(idx, &query).await.unwrap();
assert!(results.hits.total.value > 0);
// Delete document
let del = c.delete_doc(idx, "doc-1").await.unwrap();
assert_eq!(del.result.as_deref(), Some("deleted"));
// Delete index
c.delete_index(idx).await.unwrap();
}
#[tokio::test]
async fn cat_indices() {
wait_for_healthy("http://localhost:9200/_cluster/health", TIMEOUT).await;
let c = client();
let _indices = c.cat_indices().await.unwrap();
// Just verify it parses without error
}
}
// ---------------------------------------------------------------------------
// Prometheus
// ---------------------------------------------------------------------------
mod prometheus {
use super::*;
use sunbeam_sdk::monitoring::PrometheusClient;
fn client() -> PrometheusClient {
PrometheusClient::from_parts("http://localhost:9090/api/v1".into(), AuthMethod::None)
}
#[tokio::test]
async fn query_up() {
wait_for_healthy("http://localhost:9090/api/v1/status/buildinfo", TIMEOUT).await;
let c = client();
let result = c.build_info().await.unwrap();
assert_eq!(result.status, "success");
let result = c.query("up", None).await.unwrap();
assert_eq!(result.status, "success");
}
#[tokio::test]
async fn labels() {
wait_for_healthy("http://localhost:9090/api/v1/status/buildinfo", TIMEOUT).await;
let c = client();
let result = c.labels(None, None).await.unwrap();
assert_eq!(result.status, "success");
}
#[tokio::test]
async fn targets() {
wait_for_healthy("http://localhost:9090/api/v1/status/buildinfo", TIMEOUT).await;
let c = client();
let result = c.targets().await.unwrap();
assert_eq!(result.status, "success");
}
}
// ---------------------------------------------------------------------------
// Loki
// ---------------------------------------------------------------------------
mod loki {
use super::*;
use sunbeam_sdk::monitoring::LokiClient;
fn client() -> LokiClient {
LokiClient::from_parts("http://localhost:3100/loki/api/v1".into(), AuthMethod::None)
}
#[tokio::test]
async fn ready_and_labels() {
wait_for_healthy("http://localhost:3100/ready", TIMEOUT).await;
let c = client();
let _status = c.ready().await.unwrap();
// Loki's ring needs time to settle — retry labels a few times
for i in 0..10 {
match c.labels(None, None).await {
Ok(labels) => {
assert_eq!(labels.status, "success");
return;
}
Err(_) if i < 9 => {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
Err(e) => panic!("loki labels failed after retries: {e}"),
}
}
}
}
// ---------------------------------------------------------------------------
// Grafana
// ---------------------------------------------------------------------------
mod grafana {
use super::*;
use sunbeam_sdk::monitoring::GrafanaClient;
fn client() -> GrafanaClient {
use base64::Engine;
let creds = base64::engine::general_purpose::STANDARD.encode("admin:admin");
GrafanaClient::from_parts(
"http://localhost:3001/api".into(),
AuthMethod::Header {
name: "Authorization",
value: format!("Basic {creds}"),
},
)
}
#[tokio::test]
async fn org() {
wait_for_healthy("http://localhost:3001/api/health", TIMEOUT).await;
let c = client();
let org = c.get_current_org().await.unwrap();
assert!(!org.name.is_empty());
}
#[tokio::test]
async fn folder_and_dashboard_crud() {
wait_for_healthy("http://localhost:3001/api/health", TIMEOUT).await;
let c = client();
// Create folder
let folder = c
.create_folder(&serde_json::json!({"title": "Integration Tests"}))
.await
.unwrap();
let folder_uid = folder.uid.clone();
assert!(!folder_uid.is_empty());
// Create dashboard in folder
let dash_body = serde_json::json!({
"dashboard": {
"title": "Test Dashboard",
"panels": [],
"schemaVersion": 30,
},
"folderUid": folder_uid,
"overwrite": false
});
let dash = c.create_dashboard(&dash_body).await.unwrap();
let dash_uid = dash.uid.clone().unwrap();
// List dashboards
let list = c.list_dashboards().await.unwrap();
assert!(list.iter().any(|d| d.uid == dash_uid));
// Delete dashboard
c.delete_dashboard(&dash_uid).await.unwrap();
// Delete folder
c.delete_folder(&folder_uid).await.unwrap();
}
}
// ---------------------------------------------------------------------------
// LiveKit
// ---------------------------------------------------------------------------
mod livekit {
use super::*;
use sunbeam_sdk::media::LiveKitClient;
use sunbeam_sdk::media::types::VideoGrants;
fn client() -> LiveKitClient {
let grants = VideoGrants {
room_create: Some(true),
room_list: Some(true),
room_join: Some(true),
..Default::default()
};
let token =
LiveKitClient::generate_access_token("devkey", "devsecret", "test-user", &grants, 300)
.expect("JWT generation failed");
LiveKitClient::from_parts("http://localhost:7880".into(), AuthMethod::Bearer(token))
}
#[tokio::test]
async fn room_crud() {
wait_for_healthy("http://localhost:7880", TIMEOUT).await;
let c = client();
// List rooms (empty initially)
let rooms = c
.list_rooms()
.await
.unwrap();
let initial_count = rooms.rooms.len();
// Create room
let room = c
.create_room(&serde_json::json!({"name": "integration-test-room"}))
.await
.unwrap();
assert_eq!(room.name, "integration-test-room");
// List rooms (should have one more)
let rooms = c.list_rooms().await.unwrap();
assert_eq!(rooms.rooms.len(), initial_count + 1);
// Delete room
c.delete_room(&serde_json::json!({"room": "integration-test-room"}))
.await
.unwrap();
// Verify deleted
let rooms = c.list_rooms().await.unwrap();
assert_eq!(rooms.rooms.len(), initial_count);
}
}