- 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>
809 lines
24 KiB
Rust
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");
|
|
}
|