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:
2026-03-11 16:38:32 -07:00
parent f34e9a69a0
commit da4126c1a9
47 changed files with 6604 additions and 33 deletions

1619
clients/rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
clients/rust/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "mcias-client"
version = "0.1.0"
edition = "2021"
description = "Rust client library for the MCIAS identity and access management API"
license = "MIT"
[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
thiserror = "2"
[dev-dependencies]
wiremock = "0.6"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }

88
clients/rust/README.md Normal file
View File

@@ -0,0 +1,88 @@
# mcias-client (Rust)
Async Rust client library for the [MCIAS](../../README.md) identity and access management API.
## Requirements
- Rust 2021 edition (stable toolchain)
- Tokio async runtime
## Installation
Add to `Cargo.toml`:
```toml
[dependencies]
mcias-client = { path = "path/to/clients/rust" }
```
## Quick Start
```rust
use mcias_client::{Client, ClientOptions};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new(
"https://auth.example.com".to_string(),
ClientOptions::default(),
)?;
// Authenticate.
let (token, expires_at) = client.login("alice", "s3cret", None).await?;
println!("token expires at {expires_at}");
// The token is stored in the client automatically.
let accounts = client.list_accounts().await?;
// Revoke the token when done.
client.logout().await?;
Ok(())
}
```
## Custom CA Certificate
```rust
let ca_pem = std::fs::read("/etc/mcias/ca.pem")?;
let client = Client::new(
"https://auth.example.com".to_string(),
ClientOptions {
ca_cert_pem: Some(ca_pem),
token: None,
},
)?;
```
## Error Handling
All methods return `Result<_, MciasError>`:
```rust
use mcias_client::MciasError;
match client.login("alice", "wrongpass", None).await {
Err(MciasError::Auth { message }) => eprintln!("auth failed: {message}"),
Err(MciasError::Forbidden { message }) => eprintln!("forbidden: {message}"),
Err(MciasError::NotFound { message }) => eprintln!("not found: {message}"),
Err(MciasError::InvalidInput { message }) => eprintln!("bad input: {message}"),
Err(MciasError::Conflict { message }) => eprintln!("conflict: {message}"),
Err(MciasError::Server { status, message }) => eprintln!("server error {status}: {message}"),
Err(MciasError::Transport(e)) => eprintln!("network error: {e}"),
Err(MciasError::Decode(e)) => eprintln!("parse error: {e}"),
Ok((token, _)) => println!("ok: {token}"),
}
```
## Thread Safety
`Client` is `Send + Sync`. The internal token is wrapped in
`Arc<RwLock<Option<String>>>` for safe concurrent access.
## Running Tests
```sh
cargo test
cargo clippy -- -D warnings
```

514
clients/rust/src/lib.rs Normal file
View File

@@ -0,0 +1,514 @@
//! # mcias-client
//!
//! Async Rust client for the MCIAS (Metacircular Identity and Access System)
//! REST API.
//!
//! ## Usage
//!
//! ```rust,no_run
//! use mcias_client::{Client, ClientOptions};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let client = Client::new("https://auth.example.com", ClientOptions::default())?;
//!
//! let (token, expires_at) = client.login("alice", "s3cret", None).await?;
//! println!("Logged in, token expires at {expires_at}");
//!
//! client.logout().await?;
//! Ok(())
//! }
//! ```
//!
//! ## Thread Safety
//!
//! [`Client`] is `Clone + Send + Sync`. The internally stored bearer token is
//! protected by an `Arc<tokio::sync::RwLock<...>>` so concurrent async tasks
//! may share a single client safely.
use std::sync::Arc;
use reqwest::{header, Certificate, StatusCode};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
// ---- Error types ----
/// All errors returned by the MCIAS client.
#[derive(Debug, thiserror::Error)]
pub enum MciasError {
/// HTTP 401 — authentication required or credentials invalid.
#[error("authentication error: {0}")]
Auth(String),
/// HTTP 403 — caller lacks required role.
#[error("permission denied: {0}")]
Forbidden(String),
/// HTTP 404 — requested resource does not exist.
#[error("not found: {0}")]
NotFound(String),
/// HTTP 400 — the request payload was invalid.
#[error("invalid input: {0}")]
InvalidInput(String),
/// HTTP 409 — resource conflict (e.g. duplicate username).
#[error("conflict: {0}")]
Conflict(String),
/// HTTP 5xx — the server returned an internal error.
#[error("server error ({status}): {message}")]
Server { status: u16, message: String },
/// Transport-level error (DNS failure, connection refused, timeout, etc.).
#[error("transport error: {0}")]
Transport(#[from] reqwest::Error),
/// Response body could not be decoded.
#[error("decode error: {0}")]
Decode(String),
}
// ---- Data types ----
/// Account information returned by the server.
#[derive(Debug, Clone, Deserialize)]
pub struct Account {
pub id: String,
pub username: String,
pub account_type: String,
pub status: String,
pub created_at: String,
pub updated_at: String,
pub totp_enabled: bool,
}
/// Result of a token validation request.
#[derive(Debug, Clone, Deserialize)]
pub struct TokenClaims {
pub valid: bool,
#[serde(default)]
pub sub: String,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub expires_at: String,
}
/// The server's Ed25519 public key in JWK format.
#[derive(Debug, Clone, Deserialize)]
pub struct PublicKey {
pub kty: String,
pub crv: String,
pub x: String,
}
/// Postgres credentials returned by the server.
#[derive(Debug, Clone, Deserialize)]
pub struct PgCreds {
pub host: String,
pub port: u16,
pub database: String,
pub username: String,
pub password: String,
}
// ---- Internal request/response types ----
#[derive(Serialize)]
struct LoginRequest<'a> {
username: &'a str,
password: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
totp_code: Option<&'a str>,
}
#[derive(Deserialize)]
struct TokenResponse {
token: String,
expires_at: String,
}
#[derive(Deserialize)]
struct ErrorResponse {
#[serde(default)]
error: String,
}
// ---- Client options ----
/// Configuration options for the MCIAS client.
#[derive(Debug, Default, Clone)]
pub struct ClientOptions {
/// Optional PEM-encoded CA certificate for TLS verification.
/// Use when connecting to a server with a self-signed or private-CA cert.
pub ca_cert_pem: Option<Vec<u8>>,
/// Optional pre-existing bearer token.
pub token: Option<String>,
}
// ---- Client ----
/// Async MCIAS REST API client.
///
/// `Client` is cheaply cloneable — the internal HTTP client and token storage
/// are reference-counted. All clones share the same token.
#[derive(Clone)]
pub struct Client {
base_url: String,
http: reqwest::Client,
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
token: Arc<RwLock<Option<String>>>,
}
impl Client {
/// Create a new client.
///
/// `base_url` must be an HTTPS URL (e.g. `"https://auth.example.com"`).
/// TLS 1.2+ is enforced by the underlying `reqwest` / `rustls` stack.
pub fn new(base_url: &str, opts: ClientOptions) -> Result<Self, MciasError> {
let mut builder = reqwest::ClientBuilder::new()
// Security: enforce TLS 1.2+ minimum.
.min_tls_version(reqwest::tls::Version::TLS_1_2)
.use_rustls_tls();
if let Some(pem) = opts.ca_cert_pem {
let cert = Certificate::from_pem(&pem)
.map_err(|e| MciasError::Decode(format!("parse CA cert: {e}")))?;
builder = builder.add_root_certificate(cert);
}
let http = builder.build()?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_owned(),
http,
token: Arc::new(RwLock::new(opts.token)),
})
}
/// Return the currently stored bearer token, if any.
pub async fn token(&self) -> Option<String> {
self.token.read().await.clone()
}
/// Replace the stored bearer token.
pub async fn set_token(&self, tok: Option<String>) {
*self.token.write().await = tok;
}
// ---- Authentication ----
/// Login with username and password. On success stores the returned token
/// and returns `(token, expires_at)`.
///
/// `totp_code` may be `None` when TOTP is not enrolled.
pub async fn login(
&self,
username: &str,
password: &str,
totp_code: Option<&str>,
) -> Result<(String, String), MciasError> {
let body = LoginRequest {
username,
password,
totp_code,
};
let resp: TokenResponse = self.post("/v1/auth/login", &body).await?;
*self.token.write().await = Some(resp.token.clone());
Ok((resp.token, resp.expires_at))
}
/// Logout — revoke the current token on the server. Clears the stored token.
pub async fn logout(&self) -> Result<(), MciasError> {
self.post_empty("/v1/auth/logout").await?;
*self.token.write().await = None;
Ok(())
}
/// Renew the current token. The old token is revoked server-side; the new
/// token is stored and returned as `(token, expires_at)`.
pub async fn renew_token(&self) -> Result<(String, String), MciasError> {
let resp: TokenResponse = self.post("/v1/auth/renew", &serde_json::json!({})).await?;
*self.token.write().await = Some(resp.token.clone());
Ok((resp.token, resp.expires_at))
}
/// Validate a token. Returns [`TokenClaims`] with `valid: false` (no error)
/// if the token is invalid or revoked.
pub async fn validate_token(&self, token: &str) -> Result<TokenClaims, MciasError> {
let body = serde_json::json!({ "token": token });
self.post("/v1/token/validate", &body).await
}
// ---- Server information ----
/// Call the health endpoint. Returns `Ok(())` on HTTP 200.
pub async fn health(&self) -> Result<(), MciasError> {
self.get_empty("/v1/health").await
}
/// Return the server's Ed25519 public key in JWK format.
pub async fn get_public_key(&self) -> Result<PublicKey, MciasError> {
self.get("/v1/keys/public").await
}
// ---- Account management (admin only) ----
/// Create a new account. `account_type` must be `"human"` or `"system"`.
pub async fn create_account(
&self,
username: &str,
password: Option<&str>,
account_type: &str,
) -> Result<Account, MciasError> {
let mut body = serde_json::json!({
"username": username,
"account_type": account_type,
});
if let Some(pw) = password {
body["password"] = serde_json::Value::String(pw.to_owned());
}
self.post_expect_status("/v1/accounts", &body, StatusCode::CREATED)
.await
}
/// List all accounts.
pub async fn list_accounts(&self) -> Result<Vec<Account>, MciasError> {
self.get("/v1/accounts").await
}
/// Get a single account by UUID.
pub async fn get_account(&self, id: &str) -> Result<Account, MciasError> {
self.get(&format!("/v1/accounts/{id}")).await
}
/// Update an account's status. Allowed values: `"active"`, `"inactive"`.
pub async fn update_account(&self, id: &str, status: &str) -> Result<Account, MciasError> {
let body = serde_json::json!({ "status": status });
self.patch(&format!("/v1/accounts/{id}"), &body).await
}
/// Soft-delete an account and revoke all its tokens.
pub async fn delete_account(&self, id: &str) -> Result<(), MciasError> {
self.delete(&format!("/v1/accounts/{id}")).await
}
// ---- Role management (admin only) ----
/// Get all roles assigned to an account.
pub async fn get_roles(&self, account_id: &str) -> Result<Vec<String>, MciasError> {
self.get(&format!("/v1/accounts/{account_id}/roles")).await
}
/// Replace the complete role set for an account.
pub async fn set_roles(&self, account_id: &str, roles: &[&str]) -> Result<(), MciasError> {
let url = format!("/v1/accounts/{account_id}/roles");
self.put_no_content(&url, roles).await
}
// ---- Token management (admin only) ----
/// Issue a long-lived token for a system account.
pub async fn issue_service_token(
&self,
account_id: &str,
) -> Result<(String, String), MciasError> {
let body = serde_json::json!({ "account_id": account_id });
let resp: TokenResponse = self.post("/v1/token/issue", &body).await?;
Ok((resp.token, resp.expires_at))
}
/// Revoke a token by JTI.
pub async fn revoke_token(&self, jti: &str) -> Result<(), MciasError> {
self.delete(&format!("/v1/token/{jti}")).await
}
// ---- PG credentials (admin only) ----
/// Get decrypted Postgres credentials for an account.
pub async fn get_pg_creds(&self, account_id: &str) -> Result<PgCreds, MciasError> {
self.get(&format!("/v1/accounts/{account_id}/pgcreds"))
.await
}
/// Store Postgres credentials for an account.
pub async fn set_pg_creds(
&self,
account_id: &str,
host: &str,
port: u16,
database: &str,
username: &str,
password: &str,
) -> Result<(), MciasError> {
let body = serde_json::json!({
"host": host,
"port": port,
"database": database,
"username": username,
"password": password,
});
self.put_no_content(&format!("/v1/accounts/{account_id}/pgcreds"), &body)
.await
}
// ---- HTTP helpers ----
/// Build a request with the Authorization header set from the stored token.
/// Security: the token is read under a read-lock and is not logged.
async fn auth_header(&self) -> Option<header::HeaderValue> {
let guard = self.token.read().await;
guard.as_deref().and_then(|tok| {
header::HeaderValue::from_str(&format!("Bearer {tok}")).ok()
})
}
async fn get<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T, MciasError> {
let mut req = self.http.get(format!("{}{path}", self.base_url));
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.decode(resp).await
}
async fn get_empty(&self, path: &str) -> Result<(), MciasError> {
let mut req = self.http.get(format!("{}{path}", self.base_url));
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.expect_success(resp).await
}
async fn post<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &B,
) -> Result<T, MciasError> {
let mut req = self
.http
.post(format!("{}{path}", self.base_url))
.json(body);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.decode(resp).await
}
async fn post_expect_status<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &B,
expected: StatusCode,
) -> Result<T, MciasError> {
let mut req = self
.http
.post(format!("{}{path}", self.base_url))
.json(body);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
if resp.status() == expected {
return resp
.json::<T>()
.await
.map_err(|e| MciasError::Decode(e.to_string()));
}
Err(self.error_from_response(resp).await)
}
async fn post_empty(&self, path: &str) -> Result<(), MciasError> {
let mut req = self
.http
.post(format!("{}{path}", self.base_url))
.header(header::CONTENT_LENGTH, "0");
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.expect_success(resp).await
}
async fn patch<B: Serialize, T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &B,
) -> Result<T, MciasError> {
let mut req = self
.http
.patch(format!("{}{path}", self.base_url))
.json(body);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.decode(resp).await
}
async fn put_no_content<B: Serialize + ?Sized>(&self, path: &str, body: &B) -> Result<(), MciasError> {
let mut req = self
.http
.put(format!("{}{path}", self.base_url))
.json(body);
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.expect_success(resp).await
}
async fn delete(&self, path: &str) -> Result<(), MciasError> {
let mut req = self.http.delete(format!("{}{path}", self.base_url));
if let Some(auth) = self.auth_header().await {
req = req.header(header::AUTHORIZATION, auth);
}
let resp = req.send().await?;
self.expect_success(resp).await
}
async fn decode<T: for<'de> Deserialize<'de>>(
&self,
resp: reqwest::Response,
) -> Result<T, MciasError> {
if resp.status().is_success() {
return resp
.json::<T>()
.await
.map_err(|e| MciasError::Decode(e.to_string()));
}
Err(self.error_from_response(resp).await)
}
async fn expect_success(&self, resp: reqwest::Response) -> Result<(), MciasError> {
if resp.status().is_success() {
return Ok(());
}
Err(self.error_from_response(resp).await)
}
async fn error_from_response(&self, resp: reqwest::Response) -> MciasError {
let status = resp.status();
let message = resp
.json::<ErrorResponse>()
.await
.map(|e| if e.error.is_empty() { status.to_string() } else { e.error })
.unwrap_or_else(|_| status.to_string());
match status {
StatusCode::UNAUTHORIZED => MciasError::Auth(message),
StatusCode::FORBIDDEN => MciasError::Forbidden(message),
StatusCode::NOT_FOUND => MciasError::NotFound(message),
StatusCode::BAD_REQUEST => MciasError::InvalidInput(message),
StatusCode::CONFLICT => MciasError::Conflict(message),
s => MciasError::Server {
status: s.as_u16(),
message,
},
}
}
}

View 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");
}