#![warn(clippy::all)]
#![warn(missing_copy_implementations)]
#![warn(missing_debug_implementations)]
pub mod error;
pub mod acs;
pub mod security;
pub use security::security_and_compliance::{
SecurityAndComplianceV1Service, SecurityAndComplianceV2Service,
};
use acs::acs::AcsProject;
use security::security_and_compliance::SecurityAndComplianceProject;
pub use crate::error::SecurityError;
pub use openlark_core::config::Config;
#[derive(Debug, Clone)]
pub struct SecurityClient {
config: openlark_core::config::Config,
pub acs: AcsProject,
pub security_and_compliance: SecurityAndComplianceProject,
}
impl SecurityClient {
pub fn new(config: openlark_core::config::Config) -> Self {
Self {
acs: AcsProject::new(config.clone()),
security_and_compliance: SecurityAndComplianceProject::new(config.clone()),
config,
}
}
pub fn config(&self) -> &openlark_core::config::Config {
&self.config
}
}
pub type SecurityResult<T> = Result<T, crate::error::SecurityError>;
pub mod prelude {
pub use super::{SecurityClient, SecurityResult};
pub use super::acs::acs::AcsV1Service;
pub use super::security::security_and_compliance::{
SecurityAndComplianceV1Service, SecurityAndComplianceV2Service,
};
}
#[cfg(test)]
mod construction_tests {
use super::*;
use openlark_core::auth::{TokenProvider, TokenRequest};
use openlark_core::error::ErrorTrait;
use std::future::Future;
use std::pin::Pin;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[derive(Debug, Clone)]
struct TestTokenProvider(&'static str);
impl TokenProvider for TestTokenProvider {
fn get_token(
&self,
_request: TokenRequest,
) -> Pin<Box<dyn Future<Output = openlark_core::SDKResult<String>> + Send + '_>> {
let token = self.0.to_string();
Box::pin(async move { Ok(token) })
}
}
#[tokio::test]
async fn security_client_new_canonical_config_propagates_base_headers_and_token_provider() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/open-apis/acs/v1/users"))
.and(header("Authorization", "Bearer test_tok_from_provider"))
.and(header("X-Custom-Prop", "yes"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"code": 0,
"msg": "success",
"data": { "has_more": false, "items": [] }
})))
.mount(&server)
.await;
let base = Config::builder()
.app_id("test_app")
.app_secret("test_secret")
.base_url(server.uri())
.allow_custom_base_url(true)
.add_header("X-Custom-Prop", "yes")
.req_timeout(std::time::Duration::from_secs(30))
.max_response_size(8 * 1024 * 1024)
.build();
let config_with_provider =
base.with_token_provider(TestTokenProvider("test_tok_from_provider"));
let client = SecurityClient::new(config_with_provider);
let _resp = client
.acs
.v1()
.users()
.list()
.execute()
.await
.expect("wiremock 应返回成功响应");
let received = server.received_requests().await.unwrap_or_default();
assert_eq!(received.len(), 1, "应只发一次 security leaf 请求");
let req = &received[0];
assert!(
req.url.path() == "/open-apis/acs/v1/users"
|| req.url.as_str().contains("/acs/v1/users"),
"请求路径应指向 ACS leaf,实际: {}",
req.url
);
}
#[tokio::test]
async fn security_client_new_canonical_config_propagates_to_compliance_v2_leaf() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/open-apis/security_and_compliance/v2/device_records/mine",
))
.and(header("Authorization", "Bearer test_tok_from_provider"))
.and(header("X-Compliance-Test", "propagated"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"code": 0,
"msg": "success",
"data": { "has_more": false, "items": [] }
})))
.mount(&server)
.await;
let base = Config::builder()
.app_id("test_app")
.app_secret("test_secret")
.base_url(server.uri())
.allow_custom_base_url(true)
.add_header("X-Compliance-Test", "propagated")
.req_timeout(std::time::Duration::from_secs(30))
.max_response_size(8 * 1024 * 1024)
.build();
let config_with_provider =
base.with_token_provider(TestTokenProvider("test_tok_from_provider"));
let client = SecurityClient::new(config_with_provider);
let _ = client
.security_and_compliance
.v2()
.device_records()
.mine()
.execute()
.await
.expect("compliance v2 leaf 应成功");
let received = server.received_requests().await.unwrap_or_default();
assert_eq!(received.len(), 1, "应只发一次 compliance leaf 请求");
assert!(
received[0].url.path().contains("/device_records/mine"),
"请求路径应指向 compliance v2 leaf"
);
}
#[tokio::test]
async fn security_client_new_canonical_config_propagates_to_compliance_v1_leaf() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/open-apis/security_and_compliance/v1/openapi_logs/list_data",
))
.and(header("Authorization", "Bearer test_tok_from_provider"))
.and(header("X-Compliance-V1", "propagated"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"code": 0,
"msg": "success",
"data": { "items": [{ "request_id": "r1" }], "has_more": false }
})))
.mount(&server)
.await;
let base = Config::builder()
.app_id("test_app")
.app_secret("test_secret")
.base_url(server.uri())
.allow_custom_base_url(true)
.add_header("X-Compliance-V1", "propagated")
.build();
let config_with_provider =
base.with_token_provider(TestTokenProvider("test_tok_from_provider"));
let client = SecurityClient::new(config_with_provider);
use serde_json::json;
let _ = client
.security_and_compliance
.v1()
.openapi_logs()
.list_data()
.body(json!({ "start_time": 1700000000, "end_time": 1700003600 }))
.execute()
.await
.expect("compliance v1 leaf 应成功");
let received = server.received_requests().await.unwrap_or_default();
assert_eq!(received.len(), 1);
assert!(received[0].url.path().contains("/openapi_logs/list_data"));
}
#[tokio::test]
async fn compliance_v1_preserves_retryable_error_and_request_id() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/open-apis/security_and_compliance/v1/openapi_logs/list_data",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"code": 503,
"msg": "compliance v1 unavailable",
"request_id": "req-compliance-v1-503"
})))
.mount(&server)
.await;
let config = Config::builder()
.app_id("test_app")
.app_secret("test_secret")
.base_url(server.uri())
.allow_custom_base_url(true)
.build()
.with_token_provider(TestTokenProvider("compliance_v1_token"));
let client = SecurityClient::new(config);
let err = client
.security_and_compliance
.v1()
.openapi_logs()
.list_data()
.body(serde_json::json!({}))
.execute()
.await
.expect_err("compliance v1 业务错误必须向上传播");
assert!(err.is_retryable());
assert_eq!(err.context().request_id(), Some("req-compliance-v1-503"));
}
#[tokio::test]
async fn compliance_v2_preserves_retryable_error_and_request_id() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/open-apis/security_and_compliance/v2/device_records/mine",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"code": 429,
"msg": "compliance v2 rate limited",
"request_id": "req-compliance-v2-429"
})))
.mount(&server)
.await;
let config = Config::builder()
.app_id("test_app")
.app_secret("test_secret")
.base_url(server.uri())
.allow_custom_base_url(true)
.build()
.with_token_provider(TestTokenProvider("compliance_v2_token"));
let client = SecurityClient::new(config);
let err = client
.security_and_compliance
.v2()
.device_records()
.mine()
.execute()
.await
.expect_err("compliance v2 业务错误必须向上传播");
assert!(err.is_retryable());
assert_eq!(err.context().request_id(), Some("req-compliance-v2-429"));
}
#[tokio::test]
async fn security_client_timeout_propagates_and_triggers_timeout_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/open-apis/acs/v1/users"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"code":0,"msg":"ok","data":{"has_more":false,"items":[]}}))
.set_delay(std::time::Duration::from_millis(800)),
)
.mount(&server)
.await;
let base = Config::builder()
.app_id("test_app")
.app_secret("test_secret")
.base_url(server.uri())
.allow_custom_base_url(true)
.req_timeout(std::time::Duration::from_millis(50)) .build();
let config = base.with_token_provider(TestTokenProvider("test_tok_for_timeout_test"));
let client = SecurityClient::new(config);
let result = client.acs.v1().users().list().execute().await;
assert!(result.is_err(), "应因 timeout 配置触发错误");
let err = result.unwrap_err().to_string().to_lowercase();
assert!(
err.contains("timeout")
|| err.contains("time")
|| err.contains("deadline")
|| err.contains("network")
|| err.contains("send"),
"错误应体现超时或网络失败,实际: {}",
err
);
}
#[tokio::test]
async fn security_client_max_response_size_propagates_and_triggers_size_error() {
let server = MockServer::start().await;
let big_body = serde_json::json!({
"code": 0,
"msg": "ok",
"data": { "has_more": false, "items": [ {"x": "y".repeat(1024)} ] }
});
let big_json = serde_json::to_vec(&big_body).unwrap();
Mock::given(method("GET"))
.and(path("/open-apis/acs/v1/users"))
.respond_with(ResponseTemplate::new(200).set_body_raw(big_json, "application/json"))
.mount(&server)
.await;
let base = Config::builder()
.app_id("test_app")
.app_secret("test_secret")
.base_url(server.uri())
.allow_custom_base_url(true)
.max_response_size(512) .build();
let config = base.with_token_provider(TestTokenProvider("test_tok_for_size_test"));
let client = SecurityClient::new(config);
let result = client.acs.v1().users().list().execute().await;
assert!(result.is_err(), "应因 response size 超限触发错误");
let err = result.unwrap_err().to_string();
assert!(
err.contains("响应体过大")
|| err.to_lowercase().contains("large")
|| err.to_lowercase().contains("size")
|| err.contains("超过限制"),
"错误应体现响应过大,实际: {}",
err
);
}
}