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>
This commit is contained in:
@@ -70,7 +70,7 @@ pub enum MciasError {
|
||||
Decode(String),
|
||||
}
|
||||
|
||||
// ---- Data types ----
|
||||
// ---- Public data types ----
|
||||
|
||||
/// Account information returned by the server.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -101,6 +101,11 @@ pub struct TokenClaims {
|
||||
pub struct PublicKey {
|
||||
pub kty: String,
|
||||
pub crv: String,
|
||||
/// Key use — always `"sig"` for the MCIAS signing key.
|
||||
#[serde(rename = "use")]
|
||||
pub key_use: Option<String>,
|
||||
/// Algorithm — always `"EdDSA"`. Validate this before trusting the key.
|
||||
pub alg: Option<String>,
|
||||
pub x: String,
|
||||
}
|
||||
|
||||
@@ -114,6 +119,106 @@ pub struct PgCreds {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Audit log entry returned by `GET /v1/audit`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AuditEvent {
|
||||
pub id: i64,
|
||||
pub event_type: String,
|
||||
pub event_time: String,
|
||||
pub ip_address: String,
|
||||
pub actor_id: Option<String>,
|
||||
pub target_id: Option<String>,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
/// Paginated response from `GET /v1/audit`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AuditPage {
|
||||
pub events: Vec<AuditEvent>,
|
||||
pub total: i64,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
/// Query parameters for `GET /v1/audit`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AuditQuery {
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
pub event_type: Option<String>,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A single operator-defined policy rule.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PolicyRule {
|
||||
pub id: i64,
|
||||
pub priority: i64,
|
||||
pub description: String,
|
||||
pub rule: RuleBody,
|
||||
pub enabled: bool,
|
||||
pub not_before: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// The match conditions and effect of a policy rule.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuleBody {
|
||||
pub effect: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub roles: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub account_types: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subject_uuid: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub actions: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_matches_subject: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_names: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub required_tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Request body for `POST /v1/policy/rules`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CreatePolicyRuleRequest {
|
||||
pub description: String,
|
||||
pub rule: RuleBody,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub not_before: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for `PATCH /v1/policy/rules/{id}`.
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct UpdatePolicyRuleRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rule: Option<RuleBody>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub not_before: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub clear_not_before: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub clear_expires_at: Option<bool>,
|
||||
}
|
||||
|
||||
// ---- Internal request/response types ----
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -136,6 +241,22 @@ struct ErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RolesResponse {
|
||||
roles: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TagsResponse {
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TotpEnrollResponse {
|
||||
secret: String,
|
||||
otpauth_uri: String,
|
||||
}
|
||||
|
||||
// ---- Client options ----
|
||||
|
||||
/// Configuration options for the MCIAS client.
|
||||
@@ -160,6 +281,7 @@ pub struct Client {
|
||||
base_url: String,
|
||||
http: reqwest::Client,
|
||||
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
|
||||
/// Security: the token is never logged or included in error messages.
|
||||
token: Arc<RwLock<Option<String>>>,
|
||||
}
|
||||
|
||||
@@ -285,9 +407,9 @@ impl Client {
|
||||
}
|
||||
|
||||
/// Update an account's status. Allowed values: `"active"`, `"inactive"`.
|
||||
pub async fn update_account(&self, id: &str, status: &str) -> Result<Account, MciasError> {
|
||||
pub async fn update_account(&self, id: &str, status: &str) -> Result<(), MciasError> {
|
||||
let body = serde_json::json!({ "status": status });
|
||||
self.patch(&format!("/v1/accounts/{id}"), &body).await
|
||||
self.patch_no_content(&format!("/v1/accounts/{id}"), &body).await
|
||||
}
|
||||
|
||||
/// Soft-delete an account and revoke all its tokens.
|
||||
@@ -299,13 +421,17 @@ impl Client {
|
||||
|
||||
/// 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
|
||||
// Security: spec wraps roles in {"roles": [...]}, unwrap before returning.
|
||||
let resp: RolesResponse = self.get(&format!("/v1/accounts/{account_id}/roles")).await?;
|
||||
Ok(resp.roles)
|
||||
}
|
||||
|
||||
/// 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
|
||||
// Spec requires {"roles": [...]} wrapper.
|
||||
let body = serde_json::json!({ "roles": roles });
|
||||
self.put_no_content(&url, &body).await
|
||||
}
|
||||
|
||||
// ---- Token management (admin only) ----
|
||||
@@ -354,10 +480,142 @@ impl Client {
|
||||
.await
|
||||
}
|
||||
|
||||
// ---- TOTP enrollment (authenticated) ----
|
||||
|
||||
/// Begin TOTP enrollment. Returns `(secret, otpauth_uri)`.
|
||||
/// The secret is shown once; store it in an authenticator app immediately.
|
||||
pub async fn enroll_totp(&self) -> Result<(String, String), MciasError> {
|
||||
let resp: TotpEnrollResponse =
|
||||
self.post("/v1/auth/totp/enroll", &serde_json::json!({})).await?;
|
||||
Ok((resp.secret, resp.otpauth_uri))
|
||||
}
|
||||
|
||||
/// Confirm TOTP enrollment with the current 6-digit code.
|
||||
/// On success, TOTP becomes required for all future logins.
|
||||
pub async fn confirm_totp(&self, code: &str) -> Result<(), MciasError> {
|
||||
let body = serde_json::json!({ "code": code });
|
||||
self.post_empty_body("/v1/auth/totp/confirm", &body).await
|
||||
}
|
||||
|
||||
// ---- Password management ----
|
||||
|
||||
/// Change the caller's own password (self-service). Requires the current
|
||||
/// password to guard against token-theft attacks.
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
current_password: &str,
|
||||
new_password: &str,
|
||||
) -> Result<(), MciasError> {
|
||||
let body = serde_json::json!({
|
||||
"current_password": current_password,
|
||||
"new_password": new_password,
|
||||
});
|
||||
self.put_no_content("/v1/auth/password", &body).await
|
||||
}
|
||||
|
||||
// ---- Admin: TOTP removal ----
|
||||
|
||||
/// Remove TOTP enrollment from an account (admin). Use for recovery when
|
||||
/// a user loses their TOTP device.
|
||||
pub async fn remove_totp(&self, account_id: &str) -> Result<(), MciasError> {
|
||||
let body = serde_json::json!({ "account_id": account_id });
|
||||
self.delete_with_body("/v1/auth/totp", &body).await
|
||||
}
|
||||
|
||||
// ---- Admin: password reset ----
|
||||
|
||||
/// Reset an account's password without requiring the current password.
|
||||
pub async fn admin_set_password(
|
||||
&self,
|
||||
account_id: &str,
|
||||
new_password: &str,
|
||||
) -> Result<(), MciasError> {
|
||||
let body = serde_json::json!({ "new_password": new_password });
|
||||
self.put_no_content(&format!("/v1/accounts/{account_id}/password"), &body)
|
||||
.await
|
||||
}
|
||||
|
||||
// ---- Account tags (admin) ----
|
||||
|
||||
/// Get all tags for an account.
|
||||
pub async fn get_tags(&self, account_id: &str) -> Result<Vec<String>, MciasError> {
|
||||
let resp: TagsResponse =
|
||||
self.get(&format!("/v1/accounts/{account_id}/tags")).await?;
|
||||
Ok(resp.tags)
|
||||
}
|
||||
|
||||
/// Replace the full tag set for an account atomically. Pass an empty slice
|
||||
/// to clear all tags. Returns the updated tag list.
|
||||
pub async fn set_tags(
|
||||
&self,
|
||||
account_id: &str,
|
||||
tags: &[&str],
|
||||
) -> Result<Vec<String>, MciasError> {
|
||||
let body = serde_json::json!({ "tags": tags });
|
||||
let resp: TagsResponse =
|
||||
self.put_with_response(&format!("/v1/accounts/{account_id}/tags"), &body).await?;
|
||||
Ok(resp.tags)
|
||||
}
|
||||
|
||||
// ---- Audit log (admin) ----
|
||||
|
||||
/// Query the audit log. Returns a paginated [`AuditPage`].
|
||||
pub async fn list_audit(&self, query: AuditQuery) -> Result<AuditPage, MciasError> {
|
||||
let mut params: Vec<(&str, String)> = Vec::new();
|
||||
if let Some(limit) = query.limit {
|
||||
params.push(("limit", limit.to_string()));
|
||||
}
|
||||
if let Some(offset) = query.offset {
|
||||
params.push(("offset", offset.to_string()));
|
||||
}
|
||||
if let Some(ref et) = query.event_type {
|
||||
params.push(("event_type", et.clone()));
|
||||
}
|
||||
if let Some(ref aid) = query.actor_id {
|
||||
params.push(("actor_id", aid.clone()));
|
||||
}
|
||||
self.get_with_query("/v1/audit", ¶ms).await
|
||||
}
|
||||
|
||||
// ---- Policy rules (admin) ----
|
||||
|
||||
/// List all operator-defined policy rules ordered by priority.
|
||||
pub async fn list_policy_rules(&self) -> Result<Vec<PolicyRule>, MciasError> {
|
||||
self.get("/v1/policy/rules").await
|
||||
}
|
||||
|
||||
/// Create a new policy rule.
|
||||
pub async fn create_policy_rule(
|
||||
&self,
|
||||
req: CreatePolicyRuleRequest,
|
||||
) -> Result<PolicyRule, MciasError> {
|
||||
self.post_expect_status("/v1/policy/rules", &req, StatusCode::CREATED)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a single policy rule by ID.
|
||||
pub async fn get_policy_rule(&self, id: i64) -> Result<PolicyRule, MciasError> {
|
||||
self.get(&format!("/v1/policy/rules/{id}")).await
|
||||
}
|
||||
|
||||
/// Update a policy rule. Omitted fields are left unchanged.
|
||||
pub async fn update_policy_rule(
|
||||
&self,
|
||||
id: i64,
|
||||
req: UpdatePolicyRuleRequest,
|
||||
) -> Result<PolicyRule, MciasError> {
|
||||
self.patch(&format!("/v1/policy/rules/{id}"), &req).await
|
||||
}
|
||||
|
||||
/// Delete a policy rule permanently.
|
||||
pub async fn delete_policy_rule(&self, id: i64) -> Result<(), MciasError> {
|
||||
self.delete(&format!("/v1/policy/rules/{id}")).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.
|
||||
/// Build the Authorization header value from the stored token.
|
||||
/// Security: the token is read under a read-lock and is never logged.
|
||||
async fn auth_header(&self) -> Option<header::HeaderValue> {
|
||||
let guard = self.token.read().await;
|
||||
guard.as_deref().and_then(|tok| {
|
||||
@@ -383,6 +641,22 @@ impl Client {
|
||||
self.expect_success(resp).await
|
||||
}
|
||||
|
||||
async fn get_with_query<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
path: &str,
|
||||
params: &[(&str, String)],
|
||||
) -> Result<T, MciasError> {
|
||||
let mut req = self
|
||||
.http
|
||||
.get(format!("{}{path}", self.base_url))
|
||||
.query(params);
|
||||
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<B: Serialize, T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
path: &str,
|
||||
@@ -434,6 +708,19 @@ impl Client {
|
||||
self.expect_success(resp).await
|
||||
}
|
||||
|
||||
/// POST with a JSON body that expects a 2xx (no body) response.
|
||||
async fn post_empty_body<B: Serialize>(&self, path: &str, body: &B) -> Result<(), 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.expect_success(resp).await
|
||||
}
|
||||
|
||||
async fn patch<B: Serialize, T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
path: &str,
|
||||
@@ -450,6 +737,18 @@ impl Client {
|
||||
self.decode(resp).await
|
||||
}
|
||||
|
||||
async fn patch_no_content<B: Serialize>(&self, path: &str, body: &B) -> Result<(), 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.expect_success(resp).await
|
||||
}
|
||||
|
||||
async fn put_no_content<B: Serialize + ?Sized>(&self, path: &str, body: &B) -> Result<(), MciasError> {
|
||||
let mut req = self
|
||||
.http
|
||||
@@ -462,6 +761,22 @@ impl Client {
|
||||
self.expect_success(resp).await
|
||||
}
|
||||
|
||||
async fn put_with_response<B: Serialize, T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T, 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.decode(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 {
|
||||
@@ -471,6 +786,19 @@ impl Client {
|
||||
self.expect_success(resp).await
|
||||
}
|
||||
|
||||
/// DELETE with a JSON request body (used by `DELETE /v1/auth/totp`).
|
||||
async fn delete_with_body<B: Serialize>(&self, path: &str, body: &B) -> Result<(), MciasError> {
|
||||
let mut req = self
|
||||
.http
|
||||
.delete(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 decode<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
resp: reqwest::Response,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
use mcias_client::{Client, ClientOptions, MciasError};
|
||||
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()
|
||||
})
|
||||
Client::new(
|
||||
&server.uri(),
|
||||
ClientOptions {
|
||||
token: Some("admin-token".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -48,7 +54,10 @@ async fn test_health_server_error() {
|
||||
|
||||
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:?}");
|
||||
assert!(
|
||||
matches!(err, MciasError::Server { .. }),
|
||||
"expected Server error, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- public key ----
|
||||
@@ -61,6 +70,8 @@ async fn test_get_public_key() {
|
||||
.respond_with(json_body(serde_json::json!({
|
||||
"kty": "OKP",
|
||||
"crv": "Ed25519",
|
||||
"use": "sig",
|
||||
"alg": "EdDSA",
|
||||
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
|
||||
})))
|
||||
.mount(&server)
|
||||
@@ -70,6 +81,8 @@ async fn test_get_public_key() {
|
||||
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 ----
|
||||
@@ -99,7 +112,10 @@ 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"})))
|
||||
.respond_with(json_body_status(
|
||||
401,
|
||||
serde_json::json!({"error": "invalid credentials"}),
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
@@ -119,10 +135,13 @@ async fn test_logout_clears_token() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = Client::new(&server.uri(), ClientOptions {
|
||||
token: Some("existing-token".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
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");
|
||||
@@ -142,10 +161,13 @@ async fn test_renew_token() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = Client::new(&server.uri(), ClientOptions {
|
||||
token: Some("old-token".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
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");
|
||||
@@ -224,7 +246,10 @@ 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"})))
|
||||
.respond_with(json_body_status(
|
||||
409,
|
||||
serde_json::json!({"error": "username already exists"}),
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
@@ -259,7 +284,10 @@ 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"})))
|
||||
.respond_with(json_body_status(
|
||||
404,
|
||||
serde_json::json!({"error": "account not found"}),
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
@@ -271,19 +299,15 @@ async fn test_get_account_not_found() {
|
||||
#[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(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
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let c = admin_client(&server).await;
|
||||
let a = c.update_account("uuid-1", "inactive").await.unwrap();
|
||||
assert_eq!(a.status, "inactive");
|
||||
c.update_account("uuid-1", "inactive").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -305,12 +329,14 @@ async fn test_delete_account() {
|
||||
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!(["admin", "viewer"])))
|
||||
.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))
|
||||
@@ -363,7 +389,10 @@ 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"})))
|
||||
.respond_with(json_body_status(
|
||||
404,
|
||||
serde_json::json!({"error": "no pg credentials found"}),
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
@@ -405,6 +434,298 @@ async fn test_set_get_pg_creds() {
|
||||
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]
|
||||
@@ -416,11 +737,13 @@ async fn test_forbidden_error() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Use a non-admin token.
|
||||
let c = Client::new(&server.uri(), ClientOptions {
|
||||
token: Some("user-token".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
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(_)));
|
||||
|
||||
Reference in New Issue
Block a user