Implement Phase 9: client libraries (Go, Rust, Lisp, Python)
- clients/README.md: canonical API surface and error type reference - clients/testdata/: shared JSON response fixtures - clients/go/: mciasgoclient package; net/http + TLS 1.2+; sync.RWMutex token state; DisallowUnknownFields on all decoders; 25 tests pass - clients/rust/: async mcias-client crate; reqwest+rustls (no OpenSSL); thiserror MciasError enum; Arc<RwLock> token state; 22+1 tests pass; cargo clippy -D warnings clean - clients/lisp/: ASDF mcias-client; dexador HTTP, yason JSON; mcias-error condition hierarchy; Hunchentoot mock-dispatcher; 37 fiveam checks pass on SBCL 2.6.1; yason boolean normalisation in validate-token - clients/python/: mcias_client package (Python 3.11+); httpx sync; py.typed; dataclasses; 32 pytest tests; mypy --strict + ruff clean - test/mock/mockserver.go: in-memory mock server for Go client tests - ARCHITECTURE.md §19: updated per-language notes to match implementation - PROGRESS.md: Phase 9 marked complete - .gitignore: exclude clients/rust/target/, python .venv, .pytest_cache, .fasl files Security: token never logged or exposed in error messages in any library; TLS enforced in all four languages; token stored under lock/mutex/RwLock
This commit is contained in:
485
clients/rust/tests/client_tests.rs
Normal file
485
clients/rust/tests/client_tests.rs
Normal file
@@ -0,0 +1,485 @@
|
||||
use mcias_client::{Client, ClientOptions, MciasError};
|
||||
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",
|
||||
"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");
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
Mock::given(method("PATCH"))
|
||||
.and(path("/v1/accounts/uuid-1"))
|
||||
.respond_with(json_body(serde_json::json!({
|
||||
"id": "uuid-1", "username": "alice", "account_type": "human",
|
||||
"status": "inactive", "created_at": "2023-11-15T12:00:00Z",
|
||||
"updated_at": "2023-11-15T13:00:00Z", "totp_enabled": false
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = admin_client(&server).await;
|
||||
let a = c.update_account("uuid-1", "inactive").await.unwrap();
|
||||
assert_eq!(a.status, "inactive");
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/accounts/uuid-1/roles"))
|
||||
.respond_with(json_body(serde_json::json!(["admin", "viewer"])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
|
||||
// Use a non-admin token.
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user