Files
mcias/clients/rust/tests/client_tests.rs
Kyle Isom cbcb1a0533 clients: expand Go, Python, Rust client APIs
- Add TOTP enrollment/confirmation/removal to all clients
- Add password change and admin set-password endpoints
- Add account listing, status update, and tag management
- Add audit log listing with filter support
- Add policy rule CRUD operations
- Expand test coverage for all new endpoints across clients
- Fix .gitignore to exclude built binaries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 20:29:11 -07:00

809 lines
24 KiB
Rust

use mcias_client::{
AuditQuery, Client, ClientOptions, CreatePolicyRuleRequest, MciasError, RuleBody,
UpdatePolicyRuleRequest,
};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn admin_client(server: &MockServer) -> Client {
Client::new(
&server.uri(),
ClientOptions {
token: Some("admin-token".to_string()),
..Default::default()
},
)
.unwrap()
}
fn json_body(body: serde_json::Value) -> ResponseTemplate {
ResponseTemplate::new(200)
.set_body_json(body)
.insert_header("content-type", "application/json")
}
fn json_body_status(status: u16, body: serde_json::Value) -> ResponseTemplate {
ResponseTemplate::new(status)
.set_body_json(body)
.insert_header("content-type", "application/json")
}
// ---- health ----
#[tokio::test]
async fn test_health_ok() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/health"))
.respond_with(json_body(serde_json::json!({"status": "ok"})))
.mount(&server)
.await;
let c = Client::new(&server.uri(), ClientOptions::default()).unwrap();
c.health().await.expect("health should succeed");
}
#[tokio::test]
async fn test_health_server_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/health"))
.respond_with(json_body_status(500, serde_json::json!({"error": "oops"})))
.mount(&server)
.await;
let c = Client::new(&server.uri(), ClientOptions::default()).unwrap();
let err = c.health().await.unwrap_err();
assert!(
matches!(err, MciasError::Server { .. }),
"expected Server error, got {err:?}"
);
}
// ---- public key ----
#[tokio::test]
async fn test_get_public_key() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/keys/public"))
.respond_with(json_body(serde_json::json!({
"kty": "OKP",
"crv": "Ed25519",
"use": "sig",
"alg": "EdDSA",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
})))
.mount(&server)
.await;
let c = Client::new(&server.uri(), ClientOptions::default()).unwrap();
let pk = c.get_public_key().await.expect("get_public_key should succeed");
assert_eq!(pk.kty, "OKP");
assert_eq!(pk.crv, "Ed25519");
assert_eq!(pk.key_use.as_deref(), Some("sig"));
assert_eq!(pk.alg.as_deref(), Some("EdDSA"));
}
// ---- login ----
#[tokio::test]
async fn test_login_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/login"))
.respond_with(json_body(serde_json::json!({
"token": "jwt-token",
"expires_at": "2099-01-01T00:00:00Z"
})))
.mount(&server)
.await;
let c = Client::new(&server.uri(), ClientOptions::default()).unwrap();
let (tok, exp) = c.login("alice", "s3cret", None).await.unwrap();
assert_eq!(tok, "jwt-token");
assert_eq!(exp, "2099-01-01T00:00:00Z");
// Token stored in client.
assert_eq!(c.token().await.as_deref(), Some("jwt-token"));
}
#[tokio::test]
async fn test_login_bad_credentials() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/login"))
.respond_with(json_body_status(
401,
serde_json::json!({"error": "invalid credentials"}),
))
.mount(&server)
.await;
let c = Client::new(&server.uri(), ClientOptions::default()).unwrap();
let err = c.login("alice", "wrong", None).await.unwrap_err();
assert!(matches!(err, MciasError::Auth(_)), "expected Auth error, got {err:?}");
}
// ---- logout ----
#[tokio::test]
async fn test_logout_clears_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/logout"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = Client::new(
&server.uri(),
ClientOptions {
token: Some("existing-token".to_string()),
..Default::default()
},
)
.unwrap();
c.logout().await.unwrap();
assert!(c.token().await.is_none(), "token should be cleared after logout");
}
// ---- renew ----
#[tokio::test]
async fn test_renew_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/renew"))
.respond_with(json_body(serde_json::json!({
"token": "new-token",
"expires_at": "2099-06-01T00:00:00Z"
})))
.mount(&server)
.await;
let c = Client::new(
&server.uri(),
ClientOptions {
token: Some("old-token".to_string()),
..Default::default()
},
)
.unwrap();
let (tok, _) = c.renew_token().await.unwrap();
assert_eq!(tok, "new-token");
assert_eq!(c.token().await.as_deref(), Some("new-token"));
}
// ---- validate token ----
#[tokio::test]
async fn test_validate_token_valid() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/token/validate"))
.respond_with(json_body(serde_json::json!({
"valid": true,
"sub": "uuid-123",
"roles": ["admin"],
"expires_at": "2099-01-01T00:00:00Z"
})))
.mount(&server)
.await;
let c = Client::new(&server.uri(), ClientOptions::default()).unwrap();
let claims = c.validate_token("good-token").await.unwrap();
assert!(claims.valid);
assert_eq!(claims.sub, "uuid-123");
assert_eq!(claims.roles, vec!["admin"]);
}
#[tokio::test]
async fn test_validate_token_invalid() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/token/validate"))
.respond_with(json_body(serde_json::json!({"valid": false})))
.mount(&server)
.await;
let c = Client::new(&server.uri(), ClientOptions::default()).unwrap();
let claims = c.validate_token("garbage").await.unwrap();
assert!(!claims.valid, "expected valid=false");
}
// ---- accounts ----
#[tokio::test]
async fn test_create_account() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/accounts"))
.respond_with(
ResponseTemplate::new(201)
.set_body_json(serde_json::json!({
"id": "new-uuid",
"username": "bob",
"account_type": "human",
"status": "active",
"created_at": "2023-11-15T12:00:00Z",
"updated_at": "2023-11-15T12:00:00Z",
"totp_enabled": false
}))
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let c = admin_client(&server).await;
let a = c.create_account("bob", Some("pass123"), "human").await.unwrap();
assert_eq!(a.username, "bob");
assert_eq!(a.account_type, "human");
assert_eq!(a.status, "active");
}
#[tokio::test]
async fn test_create_account_conflict() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/accounts"))
.respond_with(json_body_status(
409,
serde_json::json!({"error": "username already exists"}),
))
.mount(&server)
.await;
let c = admin_client(&server).await;
let err = c.create_account("dup", Some("pass"), "human").await.unwrap_err();
assert!(matches!(err, MciasError::Conflict(_)), "expected Conflict error, got {err:?}");
}
#[tokio::test]
async fn test_list_accounts() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/accounts"))
.respond_with(json_body(serde_json::json!([
{
"id": "uuid-1", "username": "alice", "account_type": "human",
"status": "active", "created_at": "2023-11-15T12:00:00Z",
"updated_at": "2023-11-15T12:00:00Z", "totp_enabled": false
}
])))
.mount(&server)
.await;
let c = admin_client(&server).await;
let accounts = c.list_accounts().await.unwrap();
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].username, "alice");
}
#[tokio::test]
async fn test_get_account_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/accounts/missing"))
.respond_with(json_body_status(
404,
serde_json::json!({"error": "account not found"}),
))
.mount(&server)
.await;
let c = admin_client(&server).await;
let err = c.get_account("missing").await.unwrap_err();
assert!(matches!(err, MciasError::NotFound(_)), "expected NotFound, got {err:?}");
}
#[tokio::test]
async fn test_update_account() {
let server = MockServer::start().await;
// PATCH /v1/accounts/{id} returns 204 No Content per spec.
Mock::given(method("PATCH"))
.and(path("/v1/accounts/uuid-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.update_account("uuid-1", "inactive").await.unwrap();
}
#[tokio::test]
async fn test_delete_account() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/v1/accounts/uuid-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.delete_account("uuid-1").await.unwrap();
}
// ---- roles ----
#[tokio::test]
async fn test_get_set_roles() {
let server = MockServer::start().await;
// Spec wraps the array: {"roles": [...]}
Mock::given(method("GET"))
.and(path("/v1/accounts/uuid-1/roles"))
.respond_with(json_body(serde_json::json!({"roles": ["admin", "viewer"]})))
.mount(&server)
.await;
// Spec requires {"roles": [...]} in the PUT body.
Mock::given(method("PUT"))
.and(path("/v1/accounts/uuid-1/roles"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
let roles = c.get_roles("uuid-1").await.unwrap();
assert_eq!(roles, vec!["admin", "viewer"]);
c.set_roles("uuid-1", &["editor"]).await.unwrap();
}
// ---- tokens ----
#[tokio::test]
async fn test_issue_service_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/token/issue"))
.respond_with(json_body(serde_json::json!({
"token": "svc-token",
"expires_at": "2099-01-01T00:00:00Z"
})))
.mount(&server)
.await;
let c = admin_client(&server).await;
let (tok, _) = c.issue_service_token("svc-uuid").await.unwrap();
assert_eq!(tok, "svc-token");
}
#[tokio::test]
async fn test_revoke_token() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/v1/token/some-jti"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.revoke_token("some-jti").await.unwrap();
}
// ---- pg creds ----
#[tokio::test]
async fn test_pg_creds_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/accounts/uuid-1/pgcreds"))
.respond_with(json_body_status(
404,
serde_json::json!({"error": "no pg credentials found"}),
))
.mount(&server)
.await;
let c = admin_client(&server).await;
let err = c.get_pg_creds("uuid-1").await.unwrap_err();
assert!(matches!(err, MciasError::NotFound(_)));
}
#[tokio::test]
async fn test_set_get_pg_creds() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/v1/accounts/uuid-1/pgcreds"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/accounts/uuid-1/pgcreds"))
.respond_with(json_body(serde_json::json!({
"host": "db.example.com",
"port": 5432,
"database": "mydb",
"username": "dbuser",
"password": "dbpass"
})))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.set_pg_creds("uuid-1", "db.example.com", 5432, "mydb", "dbuser", "dbpass")
.await
.unwrap();
let creds = c.get_pg_creds("uuid-1").await.unwrap();
assert_eq!(creds.host, "db.example.com");
assert_eq!(creds.port, 5432);
assert_eq!(creds.password, "dbpass");
}
// ---- TOTP ----
#[tokio::test]
async fn test_enroll_totp() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/totp/enroll"))
.respond_with(json_body(serde_json::json!({
"secret": "JBSWY3DPEHPK3PXP",
"otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"
})))
.mount(&server)
.await;
let c = admin_client(&server).await;
let (secret, uri) = c.enroll_totp().await.unwrap();
assert_eq!(secret, "JBSWY3DPEHPK3PXP");
assert!(uri.starts_with("otpauth://totp/"));
}
#[tokio::test]
async fn test_confirm_totp() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/totp/confirm"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.confirm_totp("123456").await.unwrap();
}
#[tokio::test]
async fn test_remove_totp() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/v1/auth/totp"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.remove_totp("some-account-uuid").await.unwrap();
}
// ---- password management ----
#[tokio::test]
async fn test_change_password() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/v1/auth/password"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.change_password("old-pass", "new-pass-long-enough").await.unwrap();
}
#[tokio::test]
async fn test_change_password_wrong_current() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/v1/auth/password"))
.respond_with(json_body_status(
401,
serde_json::json!({"error": "current password is incorrect", "code": "unauthorized"}),
))
.mount(&server)
.await;
let c = admin_client(&server).await;
let err = c
.change_password("wrong", "new-pass-long-enough")
.await
.unwrap_err();
assert!(matches!(err, MciasError::Auth(_)));
}
#[tokio::test]
async fn test_admin_set_password() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/v1/accounts/uuid-1/password"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.admin_set_password("uuid-1", "new-pass-long-enough").await.unwrap();
}
// ---- tags ----
#[tokio::test]
async fn test_get_set_tags() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/accounts/uuid-1/tags"))
.respond_with(json_body(
serde_json::json!({"tags": ["env:production", "svc:payments-api"]}),
))
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/v1/accounts/uuid-1/tags"))
.respond_with(json_body(serde_json::json!({"tags": ["env:staging"]})))
.mount(&server)
.await;
let c = admin_client(&server).await;
let tags = c.get_tags("uuid-1").await.unwrap();
assert_eq!(tags, vec!["env:production", "svc:payments-api"]);
let updated = c.set_tags("uuid-1", &["env:staging"]).await.unwrap();
assert_eq!(updated, vec!["env:staging"]);
}
// ---- audit log ----
#[tokio::test]
async fn test_list_audit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/audit"))
.respond_with(json_body(serde_json::json!({
"events": [
{
"id": 1,
"event_type": "login_ok",
"event_time": "2026-03-11T09:01:23Z",
"ip_address": "192.0.2.1",
"actor_id": "uuid-1",
"target_id": null,
"details": null
}
],
"total": 1,
"limit": 50,
"offset": 0
})))
.mount(&server)
.await;
let c = admin_client(&server).await;
let page = c.list_audit(AuditQuery::default()).await.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.events.len(), 1);
assert_eq!(page.events[0].event_type, "login_ok");
}
// ---- policy rules ----
#[tokio::test]
async fn test_list_policy_rules() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/policy/rules"))
.respond_with(json_body(serde_json::json!([])))
.mount(&server)
.await;
let c = admin_client(&server).await;
let rules = c.list_policy_rules().await.unwrap();
assert!(rules.is_empty());
}
#[tokio::test]
async fn test_create_policy_rule() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/policy/rules"))
.respond_with(
ResponseTemplate::new(201)
.set_body_json(serde_json::json!({
"id": 1,
"priority": 100,
"description": "Allow payments-api to read its own pgcreds",
"rule": {"effect": "allow", "roles": ["svc:payments-api"]},
"enabled": true,
"not_before": null,
"expires_at": null,
"created_at": "2026-03-11T09:00:00Z",
"updated_at": "2026-03-11T09:00:00Z"
}))
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let c = admin_client(&server).await;
let rule = c
.create_policy_rule(CreatePolicyRuleRequest {
description: "Allow payments-api to read its own pgcreds".to_string(),
rule: RuleBody {
effect: "allow".to_string(),
roles: Some(vec!["svc:payments-api".to_string()]),
account_types: None,
subject_uuid: None,
actions: None,
resource_type: None,
owner_matches_subject: None,
service_names: None,
required_tags: None,
},
priority: None,
not_before: None,
expires_at: None,
})
.await
.unwrap();
assert_eq!(rule.id, 1);
assert_eq!(rule.description, "Allow payments-api to read its own pgcreds");
}
#[tokio::test]
async fn test_get_policy_rule() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/policy/rules/1"))
.respond_with(json_body(serde_json::json!({
"id": 1,
"priority": 100,
"description": "test rule",
"rule": {"effect": "deny"},
"enabled": true,
"not_before": null,
"expires_at": null,
"created_at": "2026-03-11T09:00:00Z",
"updated_at": "2026-03-11T09:00:00Z"
})))
.mount(&server)
.await;
let c = admin_client(&server).await;
let rule = c.get_policy_rule(1).await.unwrap();
assert_eq!(rule.id, 1);
assert_eq!(rule.rule.effect, "deny");
}
#[tokio::test]
async fn test_update_policy_rule() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/v1/policy/rules/1"))
.respond_with(json_body(serde_json::json!({
"id": 1,
"priority": 75,
"description": "updated rule",
"rule": {"effect": "allow"},
"enabled": false,
"not_before": null,
"expires_at": null,
"created_at": "2026-03-11T09:00:00Z",
"updated_at": "2026-03-11T10:00:00Z"
})))
.mount(&server)
.await;
let c = admin_client(&server).await;
let rule = c
.update_policy_rule(
1,
UpdatePolicyRuleRequest {
enabled: Some(false),
priority: Some(75),
..Default::default()
},
)
.await
.unwrap();
assert!(!rule.enabled);
assert_eq!(rule.priority, 75);
}
#[tokio::test]
async fn test_delete_policy_rule() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/v1/policy/rules/1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = admin_client(&server).await;
c.delete_policy_rule(1).await.unwrap();
}
// ---- error type coverage ----
#[tokio::test]
async fn test_forbidden_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/accounts"))
.respond_with(json_body_status(403, serde_json::json!({"error": "forbidden"})))
.mount(&server)
.await;
let c = Client::new(
&server.uri(),
ClientOptions {
token: Some("user-token".to_string()),
..Default::default()
},
)
.unwrap();
let err = c.list_accounts().await.unwrap_err();
assert!(matches!(err, MciasError::Forbidden(_)));
}
// ---- integration: login → validate → logout ----
#[tokio::test]
async fn test_integration_login_validate_logout() {
let server = MockServer::start().await;
// Login
Mock::given(method("POST"))
.and(path("/v1/auth/login"))
.respond_with(json_body(serde_json::json!({
"token": "integration-token",
"expires_at": "2099-01-01T00:00:00Z"
})))
.mount(&server)
.await;
// ValidateToken — valid
Mock::given(method("POST"))
.and(path("/v1/token/validate"))
.respond_with(json_body(serde_json::json!({
"valid": true,
"sub": "alice-uuid",
"roles": [],
"expires_at": "2099-01-01T00:00:00Z"
})))
.up_to_n_times(1)
.mount(&server)
.await;
// Logout
Mock::given(method("POST"))
.and(path("/v1/auth/logout"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
// ValidateToken — invalid (after logout, simulated by second mock)
Mock::given(method("POST"))
.and(path("/v1/token/validate"))
.respond_with(json_body(serde_json::json!({"valid": false})))
.mount(&server)
.await;
let c = Client::new(&server.uri(), ClientOptions::default()).unwrap();
let (tok, _) = c.login("alice", "s3cret", None).await.unwrap();
assert_eq!(tok, "integration-token");
let claims = c.validate_token(&tok).await.unwrap();
assert!(claims.valid, "token should be valid before logout");
c.logout().await.unwrap();
assert!(c.token().await.is_none(), "token cleared after logout");
let claims_after = c.validate_token(&tok).await.unwrap();
assert!(!claims_after.valid, "token should be invalid after logout");
}