mod common;
use std::{collections::BTreeMap, time::Duration};
use reqkey::{Client, Error, IngestEvent, VerificationReason};
use serde_json::{json, Value};
use wiremock::{
matchers::{body_json, header, method, path},
Mock, MockServer, ResponseTemplate,
};
#[tokio::test]
async fn verify_uses_python_compatible_contract_and_normalizes_result() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/key/validate"))
.and(header("authorization", "Bearer project_test"))
.and(header("user-agent", "reqkey-rust/0.1.0"))
.and(body_json(json!({
"key": "consumer_test",
"apiId": "api_payments",
"credits": 3,
"resource": "/payments"
})))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Retry-After", "4.5")
.set_body_json(json!({
"valid": true,
"requestId": "request_123",
"apiId": "api_payments",
"apiName": "Payments",
"resource": "/payments",
"creditsRemaining": 7,
"creditsLimit": 10,
"allowedApis": ["api_payments", 42],
"rateLimit": {"remaining": 7}
})),
)
.mount(&server)
.await;
let decision = common::client(&server)
.verify("consumer_test")
.api_id("api_payments")
.credits(3)
.resource("/payments")
.send()
.await
.expect("decision");
assert!(decision.allowed());
assert_eq!(decision.reason, VerificationReason::Valid);
assert_eq!(decision.request_id.as_deref(), Some("request_123"));
assert_eq!(decision.credits_remaining, Some(7));
assert_eq!(decision.allowed_apis, ["api_payments", "42"]);
assert_eq!(decision.retry_after, Some(4.5));
assert_eq!(decision.raw["apiName"], "Payments");
}
#[tokio::test]
async fn denial_statuses_are_results_but_project_401_is_an_error() {
for (status, expected) in [
(402, VerificationReason::InsufficientCredits),
(403, VerificationReason::Forbidden),
(429, VerificationReason::RateLimited),
] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/key/validate"))
.respond_with(ResponseTemplate::new(status).set_body_json(json!({"valid": false})))
.mount(&server)
.await;
let decision = common::client(&server)
.verify("consumer_test")
.send()
.await
.expect("access decision");
assert!(!decision.valid);
assert_eq!(decision.reason, expected);
}
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/key/validate"))
.respond_with(
ResponseTemplate::new(401).set_body_json(json!({"error": "expired project key"})),
)
.mount(&server)
.await;
let error = common::client(&server)
.verify("consumer_test")
.send()
.await
.expect_err("authentication error");
assert!(matches!(
error,
Error::Authentication {
status: 401,
ref message,
..
} if message == "expired project key"
));
}
#[tokio::test]
async fn invalid_input_and_unexpected_bodies_are_typed_errors() {
let server = MockServer::start().await;
let client = common::client(&server);
assert!(matches!(
client.verify(" ").send().await,
Err(Error::Configuration(_))
));
Mock::given(method("POST"))
.and(path("/key/validate"))
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
.mount(&server)
.await;
assert!(matches!(
client.verify("consumer_test").send().await,
Err(Error::Api {
status: 200,
body: None,
..
})
));
assert!(matches!(
IngestEvent::builder().method("GET").build(),
Err(Error::Configuration(_))
));
}
#[tokio::test]
async fn ingest_supports_full_metadata_and_truncates_unicode_by_character() {
let server = MockServer::start().await;
common::mount_ingest(&server).await;
let mut query = BTreeMap::new();
query.insert("page".into(), Value::from(2));
let long_body = "🦀".repeat(1_050);
let event = IngestEvent::builder()
.request_id("request_123")
.api_id("api_payments")
.method("POST")
.endpoint("/payments")
.path("/payments?page=2")
.status_code(201)
.latency_ms(12)
.client_ip("203.0.113.2")
.user_agent("integration-test")
.user_id("user_1")
.consumer_name("Acme")
.api_key("consumer_test")
.consumer_id("consumer_1")
.query_params(query)
.request_body(long_body)
.response_body("created")
.timestamp("2026-07-23T12:00:00Z")
.build()
.expect("event");
common::client(&server)
.ingest(&event)
.await
.expect("accepted");
let requests = server.received_requests().await.expect("requests");
let body: Value = serde_json::from_slice(&requests[0].body).expect("JSON");
assert_eq!(body["requestBody"].as_str().unwrap().chars().count(), 1_000);
assert_eq!(body["consumerName"], "Acme");
assert_eq!(body["queryParams"]["page"], 2);
}
#[tokio::test]
async fn timeout_is_distinct_from_other_transport_errors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/key/validate"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(100))
.set_body_json(json!({"valid": true})),
)
.mount(&server)
.await;
let client = Client::builder()
.project_key("project_test")
.base_url(server.uri())
.timeout(Duration::from_millis(10))
.build()
.expect("client");
assert!(matches!(
client.verify("consumer_test").send().await,
Err(Error::Timeout { .. })
));
}
#[cfg(feature = "blocking")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocking_client_uses_the_same_contract() {
let server = MockServer::start().await;
common::mount_allowed(&server).await;
let base_url = server.uri();
let decision = tokio::task::spawn_blocking(move || {
reqkey::SyncClient::builder()
.project_key("project_test")
.base_url(base_url)
.build()
.unwrap()
.verify("consumer_test")
.api_id("api_payments")
.resource("/payments")
.send()
})
.await
.unwrap()
.unwrap();
assert_eq!(decision.reason, VerificationReason::Valid);
}