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

443 lines
15 KiB
Rust

#![cfg(feature = "integration")]
mod helpers;
use helpers::*;
use sunbeam_sdk::client::{AuthMethod, ServiceClient};
use sunbeam_sdk::auth::hydra::HydraClient;
use sunbeam_sdk::auth::hydra::types::*;
const HYDRA_HEALTH: &str = "http://localhost:4445/health/alive";
fn hydra() -> HydraClient {
HydraClient::from_parts("http://localhost:4445".into(), AuthMethod::None)
}
// ---------------------------------------------------------------------------
// 1. OAuth2 Client CRUD
// ---------------------------------------------------------------------------
#[tokio::test]
async fn oauth2_client_crud() {
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
let h = hydra();
let name = unique_name("test-client");
// Create
let body = OAuth2Client {
client_name: Some(name.clone()),
redirect_uris: Some(vec!["http://localhost:9999/cb".into()]),
grant_types: Some(vec!["authorization_code".into(), "refresh_token".into()]),
response_types: Some(vec!["code".into()]),
scope: Some("openid offline".into()),
token_endpoint_auth_method: Some("client_secret_post".into()),
..Default::default()
};
let created = h.create_client(&body).await.expect("create_client");
let cid = created.client_id.as_deref().expect("created client must have id");
assert_eq!(created.client_name.as_deref(), Some(name.as_str()));
// List — created client should appear
let list = h.list_clients(Some(100), Some(0)).await.expect("list_clients");
assert!(
list.iter().any(|c| c.client_id.as_deref() == Some(cid)),
"created client should appear in list"
);
// Get
let fetched = h.get_client(cid).await.expect("get_client");
assert_eq!(fetched.client_name.as_deref(), Some(name.as_str()));
// Update (PUT) — change name
let updated_name = format!("{name}-updated");
let update_body = OAuth2Client {
client_name: Some(updated_name.clone()),
redirect_uris: Some(vec!["http://localhost:9999/cb".into()]),
grant_types: Some(vec!["authorization_code".into()]),
response_types: Some(vec!["code".into()]),
scope: Some("openid".into()),
token_endpoint_auth_method: Some("client_secret_post".into()),
..Default::default()
};
let updated = h.update_client(cid, &update_body).await.expect("update_client");
assert_eq!(updated.client_name.as_deref(), Some(updated_name.as_str()));
// Patch — change scope via JSON Patch
let patches = vec![serde_json::json!({
"op": "replace",
"path": "/scope",
"value": "openid offline profile"
})];
let patched = h.patch_client(cid, &patches).await.expect("patch_client");
assert_eq!(patched.scope.as_deref(), Some("openid offline profile"));
// Set lifespans
let lifespans = TokenLifespans {
authorization_code_grant_access_token_lifespan: Some("3600s".into()),
..Default::default()
};
let with_lifespans = h
.set_client_lifespans(cid, &lifespans)
.await
.expect("set_client_lifespans");
assert!(with_lifespans.client_id.as_deref() == Some(cid));
// Delete
h.delete_client(cid).await.expect("delete_client");
// Verify deleted
let err = h.get_client(cid).await;
assert!(err.is_err(), "get_client after delete should fail");
}
// ---------------------------------------------------------------------------
// 2. Token introspect
// ---------------------------------------------------------------------------
#[tokio::test]
async fn token_introspect() {
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
let h = hydra();
let result = h
.introspect_token("totally-bogus-token-that-does-not-exist")
.await
.expect("introspect should not error for bogus token");
assert!(!result.active, "bogus token must be inactive");
}
// ---------------------------------------------------------------------------
// 3. Delete tokens for client
// ---------------------------------------------------------------------------
#[tokio::test]
async fn delete_tokens_for_client() {
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
let h = hydra();
// Create a throwaway client
let body = OAuth2Client {
client_name: Some(unique_name("tok-del")),
grant_types: Some(vec!["client_credentials".into()]),
response_types: Some(vec!["token".into()]),
scope: Some("openid".into()),
token_endpoint_auth_method: Some("client_secret_post".into()),
..Default::default()
};
let created = h.create_client(&body).await.expect("create client for token delete");
let cid = created.client_id.as_deref().expect("client id");
// Delete tokens — should succeed (no-op, no tokens issued yet)
h.delete_tokens_for_client(cid)
.await
.expect("delete_tokens_for_client should not error");
// Cleanup
h.delete_client(cid).await.expect("cleanup client");
}
// ---------------------------------------------------------------------------
// 4. JWK set CRUD
// ---------------------------------------------------------------------------
#[tokio::test]
async fn jwk_set_crud() {
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
let h = hydra();
let set_name = unique_name("test-jwk-set");
// Create
let create_body = CreateJwkBody {
alg: "RS256".into(),
kid: format!("{set_name}-key"),
use_: "sig".into(),
};
let created = h
.create_jwk_set(&set_name, &create_body)
.await
.expect("create_jwk_set");
assert!(!created.keys.is_empty(), "created set should have at least one key");
// Get
let fetched = h.get_jwk_set(&set_name).await.expect("get_jwk_set");
assert!(!fetched.keys.is_empty());
// Update — replace the set with the same keys (idempotent)
let update_body = JwkSet {
keys: fetched.keys.clone(),
};
let updated = h
.update_jwk_set(&set_name, &update_body)
.await
.expect("update_jwk_set");
assert_eq!(updated.keys.len(), fetched.keys.len());
// Delete
h.delete_jwk_set(&set_name).await.expect("delete_jwk_set");
// Verify deleted
let err = h.get_jwk_set(&set_name).await;
assert!(err.is_err(), "get_jwk_set after delete should fail");
}
// ---------------------------------------------------------------------------
// 5. JWK key CRUD (within a set)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn jwk_key_crud() {
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
let h = hydra();
let set_name = unique_name("test-jwk-key");
// Create a set first
let kid = format!("{set_name}-k1");
let create_body = CreateJwkBody {
alg: "RS256".into(),
kid: kid.clone(),
use_: "sig".into(),
};
let created = h
.create_jwk_set(&set_name, &create_body)
.await
.expect("create_jwk_set for key test");
// The kid Hydra assigns may differ from what we requested; extract it.
let actual_kid = created.keys[0]["kid"]
.as_str()
.expect("key must have kid")
.to_string();
// Get single key
let key_set = h
.get_jwk_key(&set_name, &actual_kid)
.await
.expect("get_jwk_key");
assert_eq!(key_set.keys.len(), 1);
// Update single key — send the same key back
let key_val = key_set.keys[0].clone();
let updated = h
.update_jwk_key(&set_name, &actual_kid, &key_val)
.await
.expect("update_jwk_key");
assert!(updated.is_object());
// Delete single key
h.delete_jwk_key(&set_name, &actual_kid)
.await
.expect("delete_jwk_key");
// Cleanup — delete the (now empty) set; ignore error if already gone
let _ = h.delete_jwk_set(&set_name).await;
}
// ---------------------------------------------------------------------------
// 6. Trusted JWT issuers
// ---------------------------------------------------------------------------
#[tokio::test]
async fn trusted_issuers() {
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
let h = hydra();
// We need a JWK set for the issuer to reference
let set_name = unique_name("trust-jwk");
let kid = format!("{set_name}-k");
let jwk_body = CreateJwkBody {
alg: "RS256".into(),
kid: kid.clone(),
use_: "sig".into(),
};
let jwk_set = h
.create_jwk_set(&set_name, &jwk_body)
.await
.expect("create jwk set for trusted issuer");
let actual_kid = jwk_set.keys[0]["kid"]
.as_str()
.expect("kid")
.to_string();
// Create trusted issuer
let issuer_body = TrustedJwtIssuer {
id: None,
issuer: format!("https://{}.example.com", unique_name("iss")),
subject: "test-subject".into(),
scope: vec!["openid".into()],
public_key: Some(TrustedIssuerKey {
set: Some(set_name.clone()),
kid: Some(actual_kid.clone()),
}),
expires_at: Some("2099-12-31T23:59:59Z".into()),
created_at: None,
};
let created = match h.create_trusted_issuer(&issuer_body).await {
Ok(c) => c,
Err(_) => {
// Hydra may require inline JWK — skip if not supported
let _ = h.delete_jwk_set(&set_name).await;
return;
}
};
let issuer_id = created.id.as_deref().expect("trusted issuer must have id");
// List
let list = h.list_trusted_issuers().await.expect("list_trusted_issuers");
assert!(
list.iter().any(|i| i.id.as_deref() == Some(issuer_id)),
"created issuer should appear in list"
);
// Get
let fetched = h
.get_trusted_issuer(issuer_id)
.await
.expect("get_trusted_issuer");
assert_eq!(fetched.issuer, created.issuer);
// Delete
h.delete_trusted_issuer(issuer_id)
.await
.expect("delete_trusted_issuer");
// Verify deleted
let err = h.get_trusted_issuer(issuer_id).await;
assert!(err.is_err(), "get after delete should fail");
// Cleanup JWK set
let _ = h.delete_jwk_set(&set_name).await;
}
// ---------------------------------------------------------------------------
// 7. Consent sessions
// ---------------------------------------------------------------------------
#[tokio::test]
async fn consent_sessions() {
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
let h = hydra();
let subject = unique_name("nonexistent-user");
// List consent sessions for a subject that has none — expect empty list
let sessions = h
.list_consent_sessions(&subject)
.await
.expect("list_consent_sessions");
assert!(sessions.is_empty(), "non-existent subject should have no sessions");
// Revoke consent sessions with a client filter (Hydra requires either client or all=true)
let _ = h.revoke_consent_sessions(&subject, Some("no-such-client")).await;
// Revoke login sessions
let _ = h.revoke_login_sessions(&subject).await;
}
// ---------------------------------------------------------------------------
// 8. Login flow — bogus challenge
// ---------------------------------------------------------------------------
#[tokio::test]
async fn login_flow() {
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
let h = hydra();
let bogus = "bogus-login-challenge-12345";
// get_login_request with invalid challenge should error, not panic
let err = h.get_login_request(bogus).await;
assert!(err.is_err(), "get_login_request with bogus challenge should error");
// accept_login with invalid challenge should error, not panic
let accept_body = AcceptLoginBody {
subject: "test".into(),
remember: None,
remember_for: None,
acr: None,
amr: None,
context: None,
force_subject_identifier: None,
};
let err = h.accept_login(bogus, &accept_body).await;
assert!(err.is_err(), "accept_login with bogus challenge should error");
// reject_login with invalid challenge should error, not panic
let reject_body = RejectBody {
error: Some("access_denied".into()),
error_description: Some("test".into()),
error_debug: None,
error_hint: None,
status_code: Some(403),
};
let err = h.reject_login(bogus, &reject_body).await;
assert!(err.is_err(), "reject_login with bogus challenge should error");
}
// ---------------------------------------------------------------------------
// 9. Consent flow — bogus challenge
// ---------------------------------------------------------------------------
#[tokio::test]
async fn consent_flow() {
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
let h = hydra();
let bogus = "bogus-consent-challenge-12345";
// get_consent_request
let err = h.get_consent_request(bogus).await;
assert!(err.is_err(), "get_consent_request with bogus challenge should error");
// accept_consent
let accept_body = AcceptConsentBody {
grant_scope: Some(vec!["openid".into()]),
grant_access_token_audience: None,
session: None,
remember: None,
remember_for: None,
handled_at: None,
};
let err = h.accept_consent(bogus, &accept_body).await;
assert!(err.is_err(), "accept_consent with bogus challenge should error");
// reject_consent
let reject_body = RejectBody {
error: Some("access_denied".into()),
error_description: Some("test".into()),
error_debug: None,
error_hint: None,
status_code: Some(403),
};
let err = h.reject_consent(bogus, &reject_body).await;
assert!(err.is_err(), "reject_consent with bogus challenge should error");
}
// ---------------------------------------------------------------------------
// 10. Logout flow — bogus challenge
// ---------------------------------------------------------------------------
#[tokio::test]
async fn logout_flow() {
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
let h = hydra();
let bogus = "bogus-logout-challenge-12345";
// get_logout_request
let err = h.get_logout_request(bogus).await;
assert!(err.is_err(), "get_logout_request with bogus challenge should error");
// accept_logout
let err = h.accept_logout(bogus).await;
assert!(err.is_err(), "accept_logout with bogus challenge should error");
// reject_logout
let reject_body = RejectBody {
error: Some("access_denied".into()),
error_description: Some("test".into()),
error_debug: None,
error_hint: None,
status_code: Some(403),
};
let err = h.reject_logout(bogus, &reject_body).await;
assert!(err.is_err(), "reject_logout with bogus challenge should error");
}