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

668 lines
22 KiB
Rust

#![cfg(feature = "integration")]
mod helpers;
use helpers::*;
use sunbeam_sdk::client::{AuthMethod, ServiceClient};
use sunbeam_sdk::identity::KratosClient;
use sunbeam_sdk::identity::types::*;
const KRATOS_URL: &str = "http://localhost:4434";
const HEALTH_URL: &str = "http://localhost:4434/health/alive";
fn kratos_client() -> KratosClient {
KratosClient::from_parts(KRATOS_URL.into(), AuthMethod::None)
}
fn make_create_body(email: &str) -> CreateIdentityBody {
CreateIdentityBody {
schema_id: "default".into(),
traits: serde_json::json!({ "email": email }),
state: Some("active".into()),
metadata_public: None,
metadata_admin: None,
credentials: None,
verifiable_addresses: None,
recovery_addresses: None,
}
}
// ---------------------------------------------------------------------------
// 1. Health
// ---------------------------------------------------------------------------
#[tokio::test]
async fn health() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let alive = client.alive().await.expect("alive failed");
assert_eq!(alive.status, "ok");
let ready = client.ready().await.expect("ready failed");
assert_eq!(ready.status, "ok");
}
// ---------------------------------------------------------------------------
// 2. Identity CRUD
// ---------------------------------------------------------------------------
#[tokio::test]
async fn identity_crud() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let email = format!("{}@test.local", unique_name("crud"));
// Create
let created = client
.create_identity(&make_create_body(&email))
.await
.expect("create_identity failed");
assert_eq!(created.schema_id, "default");
assert_eq!(created.traits["email"], email);
let id = created.id.clone();
// Get
let fetched = client.get_identity(&id).await.expect("get_identity failed");
assert_eq!(fetched.id, id);
assert_eq!(fetched.traits["email"], email);
// List (should contain our identity)
let list = client
.list_identities(Some(1), Some(100))
.await
.expect("list_identities failed");
assert!(
list.iter().any(|i| i.id == id),
"created identity not found in list"
);
// Update (full replace)
let new_email = format!("{}@test.local", unique_name("updated"));
let update_body = UpdateIdentityBody {
schema_id: "default".into(),
traits: serde_json::json!({ "email": new_email }),
state: "active".into(),
metadata_public: None,
metadata_admin: None,
credentials: None,
};
let updated = client
.update_identity(&id, &update_body)
.await
.expect("update_identity failed");
assert_eq!(updated.traits["email"], new_email);
// Patch (partial update via JSON Patch)
let patch_email = format!("{}@test.local", unique_name("patched"));
let patches = vec![serde_json::json!({
"op": "replace",
"path": "/traits/email",
"value": patch_email,
})];
let patched = client
.patch_identity(&id, &patches)
.await
.expect("patch_identity failed");
assert_eq!(patched.traits["email"], patch_email);
// Delete
client
.delete_identity(&id)
.await
.expect("delete_identity failed");
// Confirm deletion
let err = client.get_identity(&id).await;
assert!(err.is_err(), "expected 404 after deletion");
}
// ---------------------------------------------------------------------------
// 3. Identity by credential identifier
// ---------------------------------------------------------------------------
#[tokio::test]
async fn identity_by_credential() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let email = format!("{}@test.local", unique_name("cred"));
let created = client
.create_identity(&make_create_body(&email))
.await
.expect("create failed");
let id = created.id.clone();
let results = client
.get_by_credential_identifier(&email)
.await
.expect("get_by_credential_identifier failed");
assert!(
results.iter().any(|i| i.id == id),
"identity not found by credential identifier"
);
// Cleanup
client.delete_identity(&id).await.expect("cleanup failed");
}
// ---------------------------------------------------------------------------
// 4. Delete credential (OIDC — may 404, which is fine)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn identity_delete_credential() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let email = format!("{}@test.local", unique_name("delcred"));
let created = client
.create_identity(&make_create_body(&email))
.await
.expect("create failed");
let id = created.id.clone();
// Attempt to delete an OIDC credential — the identity has none, so a 404
// or similar error is acceptable.
let result = client.delete_credential(&id, "oidc").await;
// We don't assert success; a 404 is the expected outcome for identities
// without OIDC credentials.
if let Err(ref e) = result {
let msg = format!("{e}");
assert!(
msg.contains("404") || msg.contains("Not Found") || msg.contains("does not have"),
"unexpected error: {msg}"
);
}
// Cleanup
client.delete_identity(&id).await.expect("cleanup failed");
}
// ---------------------------------------------------------------------------
// 5. Batch patch identities
// ---------------------------------------------------------------------------
#[tokio::test]
async fn batch_patch() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let email_a = format!("{}@test.local", unique_name("batch-a"));
let email_b = format!("{}@test.local", unique_name("batch-b"));
let body = BatchPatchIdentitiesBody {
identities: vec![
BatchPatchEntry {
create: Some(make_create_body(&email_a)),
patch_id: Some("00000000-0000-0000-0000-000000000001".into()),
},
BatchPatchEntry {
create: Some(make_create_body(&email_b)),
patch_id: Some("00000000-0000-0000-0000-000000000002".into()),
},
],
};
let result = client
.batch_patch_identities(&body)
.await
.expect("batch_patch_identities failed");
assert_eq!(result.identities.len(), 2, "expected 2 results");
// Cleanup created identities
for entry in &result.identities {
if let Some(ref identity_id) = entry.identity {
let _ = client.delete_identity(identity_id).await;
}
}
}
// ---------------------------------------------------------------------------
// 6. Sessions (global list — may be empty)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn sessions() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
// Create an identity so at least the endpoint is exercised.
let email = format!("{}@test.local", unique_name("sess"));
let created = client
.create_identity(&make_create_body(&email))
.await
.expect("create failed");
let sessions = client
.list_sessions(Some(10), None, None)
.await
.expect("list_sessions failed");
// An empty list is acceptable — no sessions exist until a login flow runs.
assert!(sessions.len() <= 10);
// Cleanup
client
.delete_identity(&created.id)
.await
.expect("cleanup failed");
}
// ---------------------------------------------------------------------------
// 7. Identity sessions
// ---------------------------------------------------------------------------
#[tokio::test]
async fn identity_sessions() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let email = format!("{}@test.local", unique_name("idsess"));
let created = client
.create_identity(&make_create_body(&email))
.await
.expect("create failed");
let id = created.id.clone();
// List sessions for this identity — expect empty.
let sessions = client
.list_identity_sessions(&id)
.await;
// Kratos may return 404 when there are no sessions, or an empty list.
match sessions {
Ok(list) => assert!(list.is_empty(), "expected no sessions for new identity"),
Err(ref e) => {
let msg = format!("{e}");
assert!(
msg.contains("404") || msg.contains("Not Found"),
"unexpected error listing identity sessions: {msg}"
);
}
}
// Delete sessions for this identity (no-op if none exist, may 404).
let del = client.delete_identity_sessions(&id).await;
if let Err(ref e) = del {
let msg = format!("{e}");
assert!(
msg.contains("404") || msg.contains("Not Found"),
"unexpected error deleting identity sessions: {msg}"
);
}
// Cleanup
client.delete_identity(&id).await.expect("cleanup failed");
}
// ---------------------------------------------------------------------------
// 8. Recovery (code + link)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn recovery() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let email = format!("{}@test.local", unique_name("recov"));
let created = client
.create_identity(&make_create_body(&email))
.await
.expect("create failed");
let id = created.id.clone();
// Recovery code
let code_result = client
.create_recovery_code(&id, Some("1h"))
.await
.expect("create_recovery_code failed");
assert!(
!code_result.recovery_link.is_empty(),
"recovery_link should not be empty"
);
assert!(
!code_result.recovery_code.is_empty(),
"recovery_code should not be empty"
);
// Recovery link (may be disabled in dev mode — handle gracefully)
match client.create_recovery_link(&id, Some("1h")).await {
Ok(link_result) => {
assert!(!link_result.recovery_link.is_empty());
}
Err(_) => {
// Endpoint disabled in dev mode — acceptable
}
}
// Cleanup
client.delete_identity(&id).await.expect("cleanup failed");
}
// ---------------------------------------------------------------------------
// 9. Schemas
// ---------------------------------------------------------------------------
#[tokio::test]
async fn schemas() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
// List schemas
let schemas = client.list_schemas().await.expect("list_schemas failed");
assert!(!schemas.is_empty(), "expected at least one schema");
assert!(
schemas.iter().any(|s| s.id == "default"),
"expected a 'default' schema in the list"
);
// Get specific schema
let schema = client
.get_schema("default")
.await
.expect("get_schema(\"default\") failed");
// The schema is a JSON Schema document; verify it has basic structure.
assert!(
schema.get("properties").is_some() || schema.get("type").is_some(),
"schema JSON should contain 'properties' or 'type'"
);
}
// ---------------------------------------------------------------------------
// 10. Courier messages
// ---------------------------------------------------------------------------
#[tokio::test]
async fn courier() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
// List courier messages (may be empty).
let messages = client
.list_courier_messages(Some(10), None)
.await
.expect("list_courier_messages failed");
// If there are messages, fetch the first one by ID.
if let Some(first) = messages.first() {
let msg = client
.get_courier_message(&first.id)
.await
.expect("get_courier_message failed");
assert_eq!(msg.id, first.id);
}
}
// ---------------------------------------------------------------------------
// 11. Get identity — nonexistent ID
// ---------------------------------------------------------------------------
#[tokio::test]
async fn get_identity_nonexistent() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let result = client
.get_identity("00000000-0000-0000-0000-000000000000")
.await;
assert!(result.is_err(), "expected error for nonexistent identity");
}
// ---------------------------------------------------------------------------
// 12. Extend session — nonexistent session
// ---------------------------------------------------------------------------
#[tokio::test]
async fn extend_session_nonexistent() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
// Extending a session that doesn't exist should fail
let result = client
.extend_session("00000000-0000-0000-0000-000000000000")
.await;
assert!(result.is_err(), "expected error for nonexistent session");
}
// ---------------------------------------------------------------------------
// 13. Disable session — nonexistent session
// ---------------------------------------------------------------------------
#[tokio::test]
async fn disable_session_nonexistent() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let result = client
.disable_session("00000000-0000-0000-0000-000000000000")
.await;
// Kratos may return 404 or similar for nonexistent sessions
assert!(result.is_err(), "expected error for nonexistent session");
}
// ---------------------------------------------------------------------------
// 14. Get session — nonexistent session
// ---------------------------------------------------------------------------
#[tokio::test]
async fn get_session_nonexistent() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let result = client
.get_session("00000000-0000-0000-0000-000000000000")
.await;
assert!(result.is_err(), "expected error for nonexistent session");
}
// ---------------------------------------------------------------------------
// 15. List sessions with active filter
// ---------------------------------------------------------------------------
#[tokio::test]
async fn list_sessions_active_filter() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
// List sessions filtering by active=true
let sessions = client
.list_sessions(Some(10), None, Some(true))
.await
.expect("list_sessions with active=true failed");
assert!(sessions.len() <= 10);
// List sessions filtering by active=false
let inactive = client
.list_sessions(Some(10), None, Some(false))
.await
.expect("list_sessions with active=false failed");
assert!(inactive.len() <= 10);
}
// ---------------------------------------------------------------------------
// 16. Patch identity — add metadata
// ---------------------------------------------------------------------------
#[tokio::test]
async fn patch_identity_metadata() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let email = format!("{}@test.local", unique_name("patchmeta"));
let created = client
.create_identity(&make_create_body(&email))
.await
.expect("create failed");
let id = created.id.clone();
// Patch: add metadata_public
let patches = vec![serde_json::json!({
"op": "add",
"path": "/metadata_public",
"value": { "role": "admin" },
})];
let patched = client
.patch_identity(&id, &patches)
.await
.expect("patch_identity with metadata failed");
assert_eq!(patched.metadata_public.as_ref().unwrap()["role"], "admin");
// Cleanup
client.delete_identity(&id).await.expect("cleanup failed");
}
// ---------------------------------------------------------------------------
// 17. List identities with default pagination
// ---------------------------------------------------------------------------
#[tokio::test]
async fn list_identities_defaults() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
// Use None for both params to exercise default path
let list = client
.list_identities(None, None)
.await
.expect("list_identities with defaults failed");
// Just verify it returns a valid vec
let _ = list;
}
// ---------------------------------------------------------------------------
// 18. Recovery code with default expiry
// ---------------------------------------------------------------------------
#[tokio::test]
async fn recovery_code_default_expiry() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let email = format!("{}@test.local", unique_name("recovdef"));
let created = client
.create_identity(&make_create_body(&email))
.await
.expect("create failed");
let id = created.id.clone();
// Recovery code with None expiry (exercises default path)
let code_result = client
.create_recovery_code(&id, None)
.await
.expect("create_recovery_code with default expiry failed");
assert!(!code_result.recovery_link.is_empty());
assert!(!code_result.recovery_code.is_empty());
// Cleanup
client.delete_identity(&id).await.expect("cleanup failed");
}
// ---------------------------------------------------------------------------
// 19. Get courier message — nonexistent
// ---------------------------------------------------------------------------
#[tokio::test]
async fn get_courier_message_nonexistent() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let result = client
.get_courier_message("00000000-0000-0000-0000-000000000000")
.await;
assert!(result.is_err(), "expected error for nonexistent courier message");
}
// ---------------------------------------------------------------------------
// 20. Get schema — nonexistent
// ---------------------------------------------------------------------------
#[tokio::test]
async fn get_schema_nonexistent() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let result = client.get_schema("nonexistent-schema-zzz").await;
assert!(result.is_err(), "expected error for nonexistent schema");
}
// ---------------------------------------------------------------------------
// 21. Patch identity — nonexistent ID
// ---------------------------------------------------------------------------
#[tokio::test]
async fn patch_identity_nonexistent() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let patches = vec![serde_json::json!({
"op": "replace",
"path": "/traits/email",
"value": "gone@test.local",
})];
let result = client
.patch_identity("00000000-0000-0000-0000-000000000000", &patches)
.await;
assert!(result.is_err(), "expected error for nonexistent identity");
}
// ---------------------------------------------------------------------------
// 22. Delete identity — nonexistent
// ---------------------------------------------------------------------------
#[tokio::test]
async fn delete_identity_nonexistent() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let result = client
.delete_identity("00000000-0000-0000-0000-000000000000")
.await;
assert!(result.is_err(), "expected error for nonexistent identity");
}
// ---------------------------------------------------------------------------
// 23. Update identity — nonexistent
// ---------------------------------------------------------------------------
#[tokio::test]
async fn update_identity_nonexistent() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
let update_body = UpdateIdentityBody {
schema_id: "default".into(),
traits: serde_json::json!({ "email": "gone@test.local" }),
state: "active".into(),
metadata_public: None,
metadata_admin: None,
credentials: None,
};
let result = client
.update_identity("00000000-0000-0000-0000-000000000000", &update_body)
.await;
assert!(result.is_err(), "expected error for nonexistent identity");
}
// ---------------------------------------------------------------------------
// 24. List courier messages with page_token
// ---------------------------------------------------------------------------
#[tokio::test]
async fn list_courier_messages_with_token() {
wait_for_healthy(HEALTH_URL, TIMEOUT).await;
let client = kratos_client();
// List with a page_token — Kratos expects base64 encoded tokens.
// Use an invalid token to exercise the code path; expect an error.
let result = client.list_courier_messages(Some(5), Some("invalid-token")).await;
assert!(result.is_err(), "invalid page_token should produce an error");
}
// ---------------------------------------------------------------------------
// 25. Connect constructor
// ---------------------------------------------------------------------------
#[tokio::test]
async fn connect_constructor() {
let client = KratosClient::connect("example.com");
assert_eq!(client.base_url(), "https://id.example.com");
assert_eq!(client.service_name(), "kratos");
}