use dataflow_rs::datalogic_rs;
use std::sync::Arc;
use serde_json::Value;
use super::ChannelRuntimeConfig;
use crate::errors::OrionError;
mod admission;
mod dedup;
mod rate_limit;
mod response_cache;
pub use dedup::DedupClaim;
pub(crate) use rate_limit::{COMMON_KEY_HEADERS, key_logic_header_paths};
pub use response_cache::CacheStoreCtx;
use admission::{acquire_backpressure, check_allowed_origin, check_auth, validate_input};
use dedup::check_deduplication;
use rate_limit::{check_principal_rate_limit, check_rate_limit};
use response_cache::{CacheLookup, check_response_cache};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
HttpSync,
HttpAsync,
Kafka,
ChannelCall,
Cron,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GuardSet {
pub auth: bool,
pub origin_allow_list: bool,
pub rate_limit: bool,
pub validation: bool,
pub deduplication: bool,
pub response_cache: bool,
pub backpressure: bool,
pub oauth2_login: bool,
}
impl Transport {
pub const fn guards(self) -> GuardSet {
match self {
Transport::HttpSync => GuardSet {
auth: true,
origin_allow_list: true,
rate_limit: true,
validation: true,
deduplication: true,
response_cache: true,
backpressure: true,
oauth2_login: true,
},
Transport::HttpAsync => GuardSet {
auth: true,
origin_allow_list: true,
rate_limit: true,
validation: true,
deduplication: true,
response_cache: false,
backpressure: true,
oauth2_login: false,
},
Transport::Kafka => GuardSet {
auth: false,
origin_allow_list: false,
rate_limit: true,
validation: true,
deduplication: true,
response_cache: false,
backpressure: true,
oauth2_login: false,
},
Transport::ChannelCall => GuardSet {
auth: false,
origin_allow_list: false,
rate_limit: true,
validation: true,
deduplication: false,
response_cache: false,
backpressure: true,
oauth2_login: false,
},
Transport::Cron => GuardSet {
auth: false,
origin_allow_list: false,
rate_limit: false,
validation: true,
deduplication: false,
response_cache: false,
backpressure: true,
oauth2_login: false,
},
}
}
}
pub type HeaderLookup<'a> = &'a (dyn Fn(&str) -> Option<String> + Send + Sync);
pub struct GuardRequest<'a> {
pub transport: Transport,
pub channel: &'a str,
pub runtime: &'a Option<Arc<ChannelRuntimeConfig>>,
pub data: &'a Value,
pub metadata: &'a Value,
pub datalogic: &'a datalogic_rs::Engine,
pub origin: Option<&'a str>,
pub caller_identity: &'a str,
pub header: HeaderLookup<'a>,
pub auth_backoff: Option<&'a crate::auth::FailedAuthTracker>,
pub raw_body: Option<&'a [u8]>,
pub dedup_key_fallback: Option<&'a str>,
pub dedup_owner: Option<&'a str>,
pub default_timeout_ms: Option<u64>,
pub max_timeout_ms: Option<u64>,
pub oauth: Option<OAuthIngress<'a>>,
}
pub struct Admission {
pub backpressure_permit: Option<tokio::sync::OwnedSemaphorePermit>,
pub cache_store: Option<CacheStoreCtx>,
pub timeout_ms: Option<u64>,
pub auth_claims: Option<Value>,
pub dedup_claim: Option<DedupClaim>,
pub oauth: Option<Box<OAuthAdmission>>,
}
pub struct OAuthIngress<'a> {
pub leg: crate::channel::OAuthLeg,
pub query: &'a std::collections::HashMap<String, String>,
pub jar: Vec<&'a str>,
}
pub struct OAuthAdmission {
pub response_cookies: Vec<String>,
pub authorize: Option<std::sync::Arc<crate::channel::CompiledOAuth2Login>>,
pub return_to: Option<String>,
pub grant: Option<Value>,
}
pub struct GuardResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: String,
}
pub enum GuardVerdict {
Admitted(Admission),
CacheHit(String),
Respond(GuardResponse),
}
pub async fn apply_guards(req: GuardRequest<'_>) -> Result<GuardVerdict, OrionError> {
let set = req.transport.guards();
if set.rate_limit {
check_rate_limit(
req.channel,
req.runtime,
req.datalogic,
req.caller_identity,
req.header,
)
.await?;
}
let auth_claims = if set.auth {
check_auth(
req.channel,
req.runtime,
req.header,
req.raw_body,
req.datalogic,
req.auth_backoff,
req.caller_identity,
)
.await?
} else {
None
};
if set.auth {
check_principal_rate_limit(
req.channel,
req.runtime,
req.datalogic,
req.caller_identity,
req.header,
auth_claims.as_ref(),
)
.await?;
}
if set.origin_allow_list {
check_allowed_origin(req.channel, req.runtime, req.origin)?;
}
if set.validation {
let metadata_with_auth = auth_claims
.as_ref()
.map(|claims| merge_auth_claims(req.metadata.clone(), claims.clone()));
validate_input(
req.channel,
req.runtime,
req.data,
metadata_with_auth.as_ref().unwrap_or(req.metadata),
req.datalogic,
)?;
}
let dedup_claim = if set.deduplication {
check_deduplication(
req.channel,
req.runtime,
req.header,
req.dedup_key_fallback,
req.dedup_owner,
)
.await?
} else {
None
};
let cache_store = if set.response_cache {
match check_response_cache(
req.channel,
req.data,
req.metadata,
req.runtime,
req.datalogic,
)
.await
{
CacheLookup::Hit(body) => return Ok(GuardVerdict::CacheHit(body)),
CacheLookup::Miss(ctx) => ctx,
}
} else {
None
};
let backpressure_permit = if set.backpressure {
match acquire_backpressure(req.channel, req.runtime) {
Ok(permit) => permit,
Err(e) => {
if let Some(claim) = dedup_claim {
claim.release().await;
}
return Err(e);
}
}
} else {
None
};
let mut oauth_authorize = None;
let mut oauth_return_to = None;
let mut response_cookies = Vec::new();
let mut oauth_metadata = None;
if set.oauth2_login
&& let Some(ingress) = req.oauth.as_ref()
&& let Some(login) = req.runtime.as_ref().and_then(|rt| rt.oauth2_login.as_ref())
{
match ingress.leg {
crate::channel::OAuthLeg::Authorize if !login.runs_workflow_on_authorize() => {
let return_to = login.accepted_return_to(ingress.query);
let redirect = match login.begin(None, return_to.as_deref()) {
Ok(redirect) => redirect,
Err(e) => {
tracing::error!(
channel = %req.channel,
error = %e,
"Could not build the OAuth2 authorize redirect"
);
if let Some(claim) = dedup_claim {
claim.release().await;
}
return Err(OrionError::internal("could not begin the sign-in"));
}
};
crate::metrics::record_oauth_login(
req.channel,
crate::channel::OAuthLeg::Authorize,
"ok",
);
return Ok(GuardVerdict::Respond(GuardResponse {
status: 302,
headers: vec![
("location".to_string(), redirect.location),
("set-cookie".to_string(), redirect.set_cookie),
("cache-control".to_string(), "no-store".to_string()),
],
body: String::new(),
}));
}
crate::channel::OAuthLeg::Authorize => {
oauth_return_to = login.accepted_return_to(ingress.query);
oauth_authorize = Some(std::sync::Arc::clone(login));
}
crate::channel::OAuthLeg::Callback => {
let grant = match login.complete(ingress.query, &ingress.jar).await {
Ok(grant) => grant,
Err(e) => {
if let Some(claim) = dedup_claim {
claim.release().await;
}
return Err(e);
}
};
response_cookies.push(grant.clear_cookie);
oauth_metadata = Some(grant.metadata);
}
}
}
let oauth = (oauth_authorize.is_some() || oauth_metadata.is_some()).then(|| {
Box::new(OAuthAdmission {
response_cookies,
authorize: oauth_authorize,
return_to: oauth_return_to,
grant: oauth_metadata,
})
});
Ok(GuardVerdict::Admitted(Admission {
backpressure_permit,
cache_store,
timeout_ms: effective_timeout_ms(req.runtime, req.default_timeout_ms, req.max_timeout_ms),
dedup_claim,
auth_claims,
oauth,
}))
}
pub async fn admit(req: GuardRequest<'_>) -> Result<Admission, OrionError> {
let transport = req.transport;
match apply_guards(req).await? {
GuardVerdict::Admitted(admission) => Ok(admission),
GuardVerdict::CacheHit(_) => Err(OrionError::internal(format!(
"{transport:?} does not enable the response cache"
))),
GuardVerdict::Respond(_) => Err(OrionError::internal(format!(
"{transport:?} cannot answer a request from a guard"
))),
}
}
pub fn merge_auth_claims(
mut metadata: serde_json::Value,
claims: serde_json::Value,
) -> serde_json::Value {
if let Some(obj) = metadata.as_object_mut() {
let mut auth = serde_json::Map::with_capacity(1);
auth.insert("claims".to_string(), claims);
obj.insert("auth".to_string(), serde_json::Value::Object(auth));
}
metadata
}
pub fn effective_timeout_ms(
runtime: &Option<Arc<ChannelRuntimeConfig>>,
default_timeout_ms: Option<u64>,
max_timeout_ms: Option<u64>,
) -> Option<u64> {
let resolved = runtime
.as_ref()
.and_then(|c| c.parsed_config.timeout_ms)
.or(default_timeout_ms)?;
Some(match max_timeout_ms {
Some(max) => resolved.min(max),
None => resolved,
})
}
pub(crate) fn is_truthy(val: &Value) -> bool {
match val {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
Value::String(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(_) => true,
}
}
#[cfg(test)]
mod tests {
use super::key_logic_header_paths;
use dataflow_rs::datalogic_rs;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use super::{
Admission, GuardRequest, GuardVerdict, HeaderLookup, Transport, Value, apply_guards,
effective_timeout_ms,
};
use crate::channel::registry::EffectiveTraceConfig;
use crate::channel::{
BackendErrorPolicy, ChannelConfig, ChannelRuntimeConfig, DeduplicationConfig,
};
use crate::config::TraceStorageConfig;
use crate::connector::cache_backend::CacheBackend;
use crate::errors::OrionError;
use crate::storage::models::Channel;
enum StubOutcome {
New,
Duplicate,
BackendError,
}
struct StubDedupBackend {
outcome: StubOutcome,
}
#[async_trait]
impl CacheBackend for StubDedupBackend {
async fn get(&self, _key: &str) -> Result<Option<String>, OrionError> {
Ok(None)
}
async fn set(&self, _key: &str, _value: &str) -> Result<(), OrionError> {
Ok(())
}
async fn set_ex(&self, _key: &str, _value: &str, _ttl: u64) -> Result<(), OrionError> {
Ok(())
}
async fn remove(&self, _key: &str) -> Result<(), OrionError> {
Ok(())
}
async fn claim_dedup_key(
&self,
_key: &str,
_owner: &str,
_window: u64,
) -> Result<Option<String>, OrionError> {
match self.outcome {
StubOutcome::New => Ok(None),
StubOutcome::Duplicate => Ok(Some(super::dedup::DEDUP_SETTLED.to_string())),
StubOutcome::BackendError => {
Err(OrionError::internal("dedup backend down".to_string()))
}
}
}
}
#[derive(Default)]
struct InMemoryDedupBackend {
held: std::sync::Mutex<std::collections::HashMap<String, String>>,
}
#[async_trait]
impl CacheBackend for InMemoryDedupBackend {
async fn get(&self, key: &str) -> Result<Option<String>, OrionError> {
Ok(self
.held
.lock()
.expect("test lock poisoned")
.get(key)
.cloned())
}
async fn set(&self, key: &str, value: &str) -> Result<(), OrionError> {
self.held
.lock()
.expect("test lock poisoned")
.insert(key.to_string(), value.to_string());
Ok(())
}
async fn set_ex(&self, key: &str, value: &str, _ttl: u64) -> Result<(), OrionError> {
self.set(key, value).await
}
async fn remove(&self, key: &str) -> Result<(), OrionError> {
self.held.lock().expect("test lock poisoned").remove(key);
Ok(())
}
async fn claim_dedup_key(
&self,
key: &str,
owner: &str,
_window: u64,
) -> Result<Option<String>, OrionError> {
let mut held = self.held.lock().expect("test lock poisoned");
match held.get(key) {
Some(holder) => Ok(Some(holder.clone())),
None => {
held.insert(key.to_string(), owner.to_string());
Ok(None)
}
}
}
}
struct CapturingDedupBackend {
seen: Arc<std::sync::Mutex<Vec<String>>>,
}
#[async_trait]
impl CacheBackend for CapturingDedupBackend {
async fn get(&self, _key: &str) -> Result<Option<String>, OrionError> {
Ok(None)
}
async fn set(&self, _key: &str, _value: &str) -> Result<(), OrionError> {
Ok(())
}
async fn set_ex(&self, _key: &str, _value: &str, _ttl: u64) -> Result<(), OrionError> {
Ok(())
}
async fn remove(&self, _key: &str) -> Result<(), OrionError> {
Ok(())
}
async fn claim_dedup_key(
&self,
key: &str,
_owner: &str,
_window: u64,
) -> Result<Option<String>, OrionError> {
self.seen
.lock()
.expect("test lock poisoned")
.push(key.to_string());
Ok(None)
}
}
struct AlwaysHitCache;
#[async_trait]
impl CacheBackend for AlwaysHitCache {
async fn get(&self, _key: &str) -> Result<Option<String>, OrionError> {
Ok(Some(r#"{"cached":true}"#.to_string()))
}
async fn set(&self, _key: &str, _value: &str) -> Result<(), OrionError> {
Ok(())
}
async fn set_ex(&self, _key: &str, _value: &str, _ttl: u64) -> Result<(), OrionError> {
Ok(())
}
async fn remove(&self, _key: &str) -> Result<(), OrionError> {
Ok(())
}
async fn claim_dedup_key(
&self,
_key: &str,
_owner: &str,
_window: u64,
) -> Result<Option<String>, OrionError> {
Ok(None)
}
}
struct FailingLimiter;
#[async_trait]
impl crate::channel::RateLimitBackend for FailingLimiter {
async fn check(&self, _key: String) -> Result<bool, OrionError> {
Err(OrionError::internal("backend down".to_string()))
}
}
struct Runtime {
parsed_config: ChannelConfig,
rate_limiter: Option<Arc<dyn crate::channel::RateLimitBackend>>,
rate_limit_key_logic: Option<datalogic_rs::Logic>,
rate_limit_key_headers: Option<Arc<[String]>>,
principal_rate_limiter: Option<Arc<dyn crate::channel::RateLimitBackend>>,
principal_rate_limit_key_logic: Option<datalogic_rs::Logic>,
validation_logic: Option<datalogic_rs::Logic>,
backpressure_semaphore: Option<Arc<tokio::sync::Semaphore>>,
dedup_store: Option<Arc<dyn CacheBackend>>,
response_cache: Option<Arc<dyn CacheBackend>>,
auth: Option<crate::channel::auth::CompiledAuth>,
}
impl Runtime {
fn new() -> Self {
Self {
parsed_config: ChannelConfig::default(),
rate_limiter: None,
rate_limit_key_logic: None,
rate_limit_key_headers: None,
principal_rate_limiter: None,
principal_rate_limit_key_logic: None,
validation_logic: None,
backpressure_semaphore: None,
auth: None,
dedup_store: None,
response_cache: None,
}
}
fn dedup(mut self, store: Arc<dyn CacheBackend>, policy: BackendErrorPolicy) -> Self {
self.parsed_config.deduplication = Some(DeduplicationConfig {
header: "idempotency-key".to_string(),
window_secs: Some(60),
connector: None,
on_backend_error: policy,
});
self.dedup_store = Some(store);
self
}
fn origins(mut self, origins: &[&str]) -> Self {
self.parsed_config.origin_allow_list =
Some(origins.iter().map(|o| o.to_string()).collect());
self
}
fn limiter(
mut self,
backend: Arc<dyn crate::channel::RateLimitBackend>,
policy: BackendErrorPolicy,
) -> Self {
self.parsed_config.rate_limit = Some(crate::channel::ChannelRateLimitConfig {
requests_per_second: 1,
burst: Some(1),
key_logic: None,
key_headers: None,
on_backend_error: policy,
});
self.rate_limiter = Some(backend);
self
}
fn principal_limiter(
mut self,
engine: &datalogic_rs::Engine,
backend: Arc<dyn crate::channel::RateLimitBackend>,
logic: serde_json::Value,
) -> Self {
self.parsed_config.principal_rate_limit =
Some(crate::channel::ChannelRateLimitConfig {
requests_per_second: 1,
burst: Some(1),
key_logic: Some(logic.clone()),
key_headers: None,
on_backend_error: BackendErrorPolicy::default(),
});
self.principal_rate_limiter = Some(backend);
self.principal_rate_limit_key_logic =
Some(engine.compile(&logic).expect("test logic compiles"));
self
}
fn key_logic(mut self, engine: &datalogic_rs::Engine, logic: serde_json::Value) -> Self {
self.rate_limit_key_logic = Some(engine.compile(&logic).expect("test logic compiles"));
self
}
fn key_headers(mut self, names: &[&str]) -> Self {
let lowered: Vec<String> = names.iter().map(|n| n.to_ascii_lowercase()).collect();
if let Some(ref mut rl) = self.parsed_config.rate_limit {
rl.key_headers = Some(lowered.clone());
}
self.rate_limit_key_headers = Some(lowered.into());
self
}
fn validation(mut self, engine: &datalogic_rs::Engine, logic: serde_json::Value) -> Self {
self.validation_logic = Some(engine.compile(&logic).expect("test logic compiles"));
self
}
fn backpressure(mut self, permits: usize) -> Self {
self.parsed_config.backpressure = Some(crate::channel::BackpressureConfig {
max_concurrent_per_node: permits,
});
self.backpressure_semaphore = Some(Arc::new(tokio::sync::Semaphore::new(permits)));
self
}
fn cache(mut self, backend: Arc<dyn CacheBackend>) -> Self {
self.parsed_config.cache = Some(crate::channel::ChannelCacheConfig {
enabled: true,
ttl_secs: Some(60),
cache_key_fields: None,
key_logic: None,
connector: None,
});
self.response_cache = Some(backend);
self
}
fn timeout_ms(mut self, ms: u64) -> Self {
self.parsed_config.timeout_ms = Some(ms);
self
}
async fn api_key(mut self, key: &str) -> Self {
let cfg = crate::channel::config::ChannelAuthConfig {
mode: crate::channel::config::AuthMode::ApiKey,
keys: Some(vec![key.to_string()]),
header: Some("X-API-Key".to_string()),
..Default::default()
};
self.auth = Some(
crate::channel::auth::CompiledAuth::compile(&cfg, None, None)
.await
.expect("test auth compiles"),
);
self.parsed_config.auth = Some(cfg);
self
}
fn build(self) -> Option<Arc<ChannelRuntimeConfig>> {
let now = chrono::Utc::now().naive_utc();
Some(Arc::new(ChannelRuntimeConfig {
channel: Channel {
tags_json: "[]".to_string(),
channel_id: "ch_test".to_string(),
version: 1,
name: "test-channel".to_string(),
description: None,
channel_type: "sync".to_string(),
protocol: "rest".to_string(),
methods_json: None,
route_pattern: None,
topic: None,
consumer_group: None,
transport_config_json: "{}".to_string(),
workflow_id: None,
config_json: "{}".to_string(),
status: "active".to_string(),
priority: 0,
created_at: now,
updated_at: now,
},
cron: None,
parsed_config: self.parsed_config,
rate_limiter: self.rate_limiter,
rate_limit_key_logic: self.rate_limit_key_logic,
principal_rate_limiter: self.principal_rate_limiter,
principal_rate_limit_key_logic: self.principal_rate_limit_key_logic,
cache_key_logic: None,
rate_limit_key_headers: self.rate_limit_key_headers,
validation_logic: self.validation_logic,
backpressure_semaphore: self.backpressure_semaphore,
dedup_store: self.dedup_store,
response_cache: self.response_cache,
trace_storage: EffectiveTraceConfig::resolve(&TraceStorageConfig::default(), None),
auth: self.auth,
oauth2_login: None,
}))
}
}
fn dedup_runtime(outcome: StubOutcome) -> Option<Arc<ChannelRuntimeConfig>> {
dedup_runtime_with_policy(outcome, BackendErrorPolicy::Allow)
}
fn dedup_runtime_with_policy(
outcome: StubOutcome,
policy: BackendErrorPolicy,
) -> Option<Arc<ChannelRuntimeConfig>> {
Runtime::new()
.dedup(Arc::new(StubDedupBackend { outcome }), policy)
.build()
}
fn idempotency_lookup(name: &str) -> Option<String> {
(name == "idempotency-key").then(|| "token-1".to_string())
}
fn no_headers(_name: &str) -> Option<String> {
None
}
const IDEMPOTENCY: HeaderLookup<'static> = &idempotency_lookup;
const NO_HEADERS: HeaderLookup<'static> = &no_headers;
fn engine() -> datalogic_rs::Engine {
datalogic_rs::Engine::new()
}
fn request<'a>(
transport: Transport,
runtime: &'a Option<Arc<ChannelRuntimeConfig>>,
datalogic: &'a datalogic_rs::Engine,
data: &'a Value,
metadata: &'a Value,
) -> GuardRequest<'a> {
GuardRequest {
transport,
channel: "orders",
runtime,
data,
metadata,
datalogic,
auth_backoff: None,
origin: None,
caller_identity: "10.0.0.1",
header: NO_HEADERS,
raw_body: None,
dedup_key_fallback: None,
dedup_owner: None,
default_timeout_ms: None,
max_timeout_ms: None,
oauth: None,
}
}
fn admitted(verdict: GuardVerdict) -> Option<Admission> {
match verdict {
GuardVerdict::Admitted(a) => Some(a),
GuardVerdict::CacheHit(_) | GuardVerdict::Respond(_) => None,
}
}
#[tokio::test]
async fn the_guard_matrix_is_what_the_docs_claim() {
let sync = Transport::HttpSync.guards();
let submit = Transport::HttpAsync.guards();
let kafka = Transport::Kafka.guards();
let call = Transport::ChannelCall.guards();
let cron = Transport::Cron.guards();
for set in [sync, submit, kafka, call, cron] {
assert!(set.validation);
assert!(set.backpressure);
}
for set in [sync, submit, kafka, call] {
assert!(set.rate_limit);
}
assert!(!cron.rate_limit);
assert!(sync.origin_allow_list && submit.origin_allow_list);
assert!(!kafka.origin_allow_list && !call.origin_allow_list && !cron.origin_allow_list);
assert!(sync.deduplication && submit.deduplication && kafka.deduplication);
assert!(!call.deduplication && !cron.deduplication);
assert!(sync.response_cache);
assert!(
!submit.response_cache
&& !kafka.response_cache
&& !call.response_cache
&& !cron.response_cache
);
assert!(sync.auth && submit.auth);
assert!(!kafka.auth && !call.auth && !cron.auth);
assert!(sync.oauth2_login);
assert!(!cron.oauth2_login);
}
#[test]
fn cron_guards_and_validation_agree() {
let cron = Transport::Cron.guards();
let refused = |key: &str| {
!crate::validation::cron_config_errors_for_test(&serde_json::json!({ key: {} }))
.is_empty()
};
for (guard_on, key) in [
(cron.auth, "auth"),
(cron.origin_allow_list, "origin_allow_list"),
(cron.rate_limit, "rate_limit"),
(cron.deduplication, "deduplication"),
(cron.response_cache, "cache"),
(cron.oauth2_login, "oauth2_login"),
] {
assert!(
guard_on || refused(key),
"Transport::Cron leaves `{key}` off, so authoring must refuse it — \
otherwise a cron channel can declare it and Orion ignores it"
);
}
assert!(cron.validation && cron.backpressure);
assert!(!refused("validation_logic") && !refused("backpressure"));
}
#[tokio::test]
async fn authentication_applies_to_every_http_ingress() {
let dl = engine();
let data = json!({});
let metadata = json!({});
for transport in [Transport::HttpSync, Transport::HttpAsync] {
let runtime = Runtime::new().api_key("s3cret").await.build();
let req = request(transport, &runtime, &dl, &data, &metadata);
assert!(
apply_guards(req).await.is_err(),
"{transport:?} admitted a request presenting no key"
);
let runtime = Runtime::new().api_key("s3cret").await.build();
let present: HeaderLookup<'_> =
&|name: &str| (name == "X-API-Key").then(|| "s3cret".to_string());
let mut req = request(transport, &runtime, &dl, &data, &metadata);
req.header = present;
assert!(
apply_guards(req).await.is_ok(),
"{transport:?} refused a request presenting the right key"
);
}
}
#[tokio::test]
async fn authentication_does_not_apply_to_kafka_or_channel_call() {
let dl = engine();
let data = json!({});
let metadata = json!({});
for transport in [Transport::Kafka, Transport::ChannelCall, Transport::Cron] {
let runtime = Runtime::new().api_key("s3cret").await.build();
let req = request(transport, &runtime, &dl, &data, &metadata);
assert!(
apply_guards(req).await.is_ok(),
"{transport:?} must not require an HTTP credential"
);
}
}
#[tokio::test]
async fn a_refused_caller_never_reaches_dedup_or_cache() {
let dl = engine();
let data = json!({});
let metadata = json!({});
let runtime = Runtime::new()
.api_key("s3cret")
.await
.dedup(
Arc::new(StubDedupBackend {
outcome: StubOutcome::BackendError,
}),
BackendErrorPolicy::Deny,
)
.build();
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &metadata);
req.header = IDEMPOTENCY;
let err = apply_guards(req)
.await
.err()
.expect("an unauthenticated caller must be refused");
assert!(
matches!(err, OrionError::Unauthorized(_)),
"expected a 401 from the auth guard, got {err:?} — the dedup guard ran first"
);
}
#[tokio::test]
async fn the_channel_timeout_outranks_the_transport_default() {
let with_timeout = Runtime::new().timeout_ms(2_000).build();
let without = Runtime::new().build();
assert_eq!(
effective_timeout_ms(&with_timeout, Some(60_000), None),
Some(2_000)
);
assert_eq!(effective_timeout_ms(&with_timeout, None, None), Some(2_000));
assert_eq!(
effective_timeout_ms(&without, Some(60_000), None),
Some(60_000)
);
assert_eq!(effective_timeout_ms(&without, None, None), None);
assert_eq!(
effective_timeout_ms(&None, Some(60_000), None),
Some(60_000)
);
}
#[tokio::test]
async fn a_transport_ceiling_clamps_an_over_long_channel_timeout() {
let over = Runtime::new().timeout_ms(600_000).build();
let under = Runtime::new().timeout_ms(2_000).build();
let none = Runtime::new().build();
assert_eq!(
effective_timeout_ms(&over, Some(30_000), Some(30_000)),
Some(30_000),
"a channel asking for 10 minutes gets the transport's ceiling"
);
assert_eq!(
effective_timeout_ms(&under, Some(30_000), Some(30_000)),
Some(2_000),
"a shorter channel deadline is still honoured"
);
assert_eq!(
effective_timeout_ms(&none, Some(30_000), Some(30_000)),
Some(30_000)
);
assert_eq!(
effective_timeout_ms(&over, None, None),
Some(600_000),
"without a ceiling the channel value is unchanged"
);
}
#[tokio::test]
async fn every_transport_carries_the_channel_timeout_into_its_admission() {
let dl = engine();
let runtime = Runtime::new().timeout_ms(2_000).build();
let (data, meta) = (json!({}), json!({}));
for transport in [
Transport::HttpSync,
Transport::HttpAsync,
Transport::Kafka,
Transport::ChannelCall,
] {
let mut req = request(transport, &runtime, &dl, &data, &meta);
req.default_timeout_ms = Some(60_000);
let admission = admitted(apply_guards(req).await.expect("guards pass"))
.expect("an admission, not a cache hit");
assert_eq!(
admission.timeout_ms,
Some(2_000),
"{transport:?} must honour the channel's timeout_ms"
);
}
}
#[tokio::test]
async fn the_rate_limit_applies_on_every_transport() {
let dl = engine();
let (data, meta) = (json!({}), json!({}));
for transport in [
Transport::HttpSync,
Transport::HttpAsync,
Transport::Kafka,
Transport::ChannelCall,
] {
let runtime = Runtime::new()
.limiter(
Arc::new(crate::channel::LocalRateLimitBackend::new(1, 1)),
BackendErrorPolicy::Allow,
)
.build();
let first = apply_guards(request(transport, &runtime, &dl, &data, &meta)).await;
assert!(first.is_ok(), "{transport:?} first call must pass");
let second = apply_guards(request(transport, &runtime, &dl, &data, &meta)).await;
assert!(
matches!(second, Err(OrionError::RateLimited(_))),
"{transport:?} must refuse the second call"
);
}
}
#[tokio::test]
async fn the_default_bucket_key_is_the_transports_own_caller_identity() {
let dl = engine();
let (data, meta) = (json!({}), json!({}));
let runtime = Runtime::new()
.limiter(
Arc::new(crate::channel::LocalRateLimitBackend::new(1, 1)),
BackendErrorPolicy::Allow,
)
.build();
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.caller_identity = "203.0.113.7";
assert!(apply_guards(req).await.is_ok());
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.caller_identity = "203.0.113.7";
assert!(matches!(
apply_guards(req).await,
Err(OrionError::RateLimited(_))
));
let mut req = request(Transport::Kafka, &runtime, &dl, &data, &meta);
req.caller_identity = "orders-topic";
assert!(
apply_guards(req).await.is_ok(),
"a Kafka record meters under its topic, not the HTTP client's bucket"
);
let shared = Runtime::new()
.limiter(
Arc::new(crate::channel::LocalRateLimitBackend::new(1, 1)),
BackendErrorPolicy::Allow,
)
.key_logic(&dl, json!({"var": "channel"}))
.build();
let mut req = request(Transport::HttpSync, &shared, &dl, &data, &meta);
req.caller_identity = "203.0.113.7";
assert!(apply_guards(req).await.is_ok());
let mut req = request(Transport::Kafka, &shared, &dl, &data, &meta);
req.caller_identity = "orders-topic";
assert!(
matches!(apply_guards(req).await, Err(OrionError::RateLimited(_))),
"a channel-keyed limit is spent by whichever ingress gets there first"
);
}
#[tokio::test]
async fn an_unevaluable_rate_limit_key_rejects_as_its_own_condition() {
let dl = engine();
let runtime = Runtime::new()
.limiter(
Arc::new(crate::channel::LocalRateLimitBackend::new(1000, 1000)),
BackendErrorPolicy::Allow,
)
.key_logic(&dl, json!({"throw": "key_unavailable"}))
.build();
let (data, meta) = (json!({}), json!({}));
let verdict = apply_guards(request(Transport::HttpSync, &runtime, &dl, &data, &meta)).await;
assert!(
matches!(verdict, Err(OrionError::RateLimitKeyUnavailable(_))),
"an unevaluable key must reject as unevaluable, not as over-limit"
);
let (status, code, _) = verdict.err().expect("a refusal").response_parts();
assert_eq!(status, axum::http::StatusCode::TOO_MANY_REQUESTS);
assert_eq!(code, "RATE_LIMITED");
}
#[tokio::test]
async fn a_limiter_backend_outage_follows_the_channel_policy() {
let dl = engine();
let (data, meta) = (json!({}), json!({}));
let allow = Runtime::new()
.limiter(Arc::new(FailingLimiter), BackendErrorPolicy::Allow)
.build();
let verdict = apply_guards(request(Transport::Kafka, &allow, &dl, &data, &meta)).await;
assert!(verdict.is_ok(), "allow must fail open");
let deny = Runtime::new()
.limiter(Arc::new(FailingLimiter), BackendErrorPolicy::Deny)
.build();
let verdict = apply_guards(request(Transport::Kafka, &deny, &dl, &data, &meta)).await;
assert!(
matches!(verdict, Err(OrionError::ServiceUnavailable { .. })),
"deny must refuse with 503, not 429"
);
}
#[tokio::test]
async fn the_rate_limit_key_can_be_computed_from_transport_headers() {
let dl = engine();
let runtime = Runtime::new()
.limiter(
Arc::new(crate::channel::LocalRateLimitBackend::new(1, 1)),
BackendErrorPolicy::Allow,
)
.key_logic(&dl, json!({"var": "headers.x-tenant-id"}))
.build();
let (data, meta) = (json!({}), json!({}));
let acme = |name: &str| (name == "x-tenant-id").then(|| "acme".to_string());
let globex = |name: &str| (name == "x-tenant-id").then(|| "globex".to_string());
let mut req = request(Transport::Kafka, &runtime, &dl, &data, &meta);
req.header = &acme;
assert!(apply_guards(req).await.is_ok());
let mut req = request(Transport::Kafka, &runtime, &dl, &data, &meta);
req.header = &globex;
assert!(apply_guards(req).await.is_ok());
let mut req = request(Transport::Kafka, &runtime, &dl, &data, &meta);
req.header = &acme;
assert!(matches!(
apply_guards(req).await,
Err(OrionError::RateLimited(_))
));
}
#[tokio::test]
async fn the_principal_limit_is_a_second_bucket_after_the_address_limit() {
let dl = engine();
let runtime = Runtime::new()
.limiter(
Arc::new(crate::channel::LocalRateLimitBackend::new(100, 100)),
BackendErrorPolicy::Allow,
)
.principal_limiter(
&dl,
Arc::new(crate::channel::LocalRateLimitBackend::new(1, 1)),
json!({"var": "headers.x-tenant-id"}),
)
.build();
let (data, meta) = (json!({}), json!({}));
let acme = |name: &str| (name == "x-tenant-id").then(|| "acme".to_string());
let globex = |name: &str| (name == "x-tenant-id").then(|| "globex".to_string());
let mut req = request(Transport::HttpAsync, &runtime, &dl, &data, &meta);
req.header = &acme;
assert!(apply_guards(req).await.is_ok());
let mut req = request(Transport::HttpAsync, &runtime, &dl, &data, &meta);
req.header = &globex;
assert!(apply_guards(req).await.is_ok());
let mut req = request(Transport::HttpAsync, &runtime, &dl, &data, &meta);
req.header = &acme;
assert!(matches!(
apply_guards(req).await,
Err(OrionError::RateLimited(_))
));
}
#[tokio::test]
async fn the_principal_key_reads_the_verified_claims() {
let dl = engine();
let runtime = Runtime::new()
.principal_limiter(
&dl,
Arc::new(crate::channel::LocalRateLimitBackend::new(1, 1)),
json!({"var": "auth.sub"}),
)
.build();
let none = |_: &str| None;
let claims = json!({"sub": "user-1", "scope": "read"});
let other = json!({"sub": "user-2"});
let check = async |claims: Option<&serde_json::Value>| {
super::check_principal_rate_limit("ch", &runtime, &dl, "10.0.0.1", &none, claims).await
};
assert!(check(Some(&claims)).await.is_ok());
assert!(check(Some(&other)).await.is_ok());
assert!(matches!(
check(Some(&claims)).await,
Err(OrionError::RateLimited(_))
));
}
#[tokio::test]
async fn a_principal_key_with_no_claims_refuses_rather_than_falling_back() {
let dl = engine();
let runtime = Runtime::new()
.principal_limiter(
&dl,
Arc::new(crate::channel::LocalRateLimitBackend::new(100, 100)),
json!({"var": "auth.sub"}),
)
.build();
let none = |_: &str| None;
assert!(matches!(
super::check_principal_rate_limit("ch", &runtime, &dl, "10.0.0.1", &none, None).await,
Err(OrionError::RateLimitKeyUnavailable(_))
));
}
#[tokio::test]
async fn a_null_rate_limit_key_refuses_rather_than_collapsing() {
let dl = engine();
let runtime = Runtime::new()
.limiter(
Arc::new(crate::channel::LocalRateLimitBackend::new(1000, 1000)),
BackendErrorPolicy::Allow,
)
.key_logic(&dl, json!({"var": "headers.deviceid"}))
.build();
let (data, meta) = (json!({}), json!({}));
let device = |name: &str| (name == "deviceid").then(|| "phone-1".to_string());
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.header = &device;
let verdict = apply_guards(req).await;
assert!(
matches!(verdict, Err(OrionError::RateLimitKeyUnavailable(_))),
"an unreachable header must refuse, not collapse every caller into one bucket"
);
let (status, code, _) = verdict.err().expect("a refusal").response_parts();
assert_eq!(status, axum::http::StatusCode::TOO_MANY_REQUESTS);
assert_eq!(code, "RATE_LIMITED");
}
#[tokio::test]
async fn two_callers_with_a_null_key_do_not_share_a_bucket() {
let dl = engine();
let runtime = Runtime::new()
.limiter(
Arc::new(crate::channel::LocalRateLimitBackend::new(1, 1)),
BackendErrorPolicy::Allow,
)
.key_logic(&dl, json!({"var": "headers.deviceid"}))
.build();
let (data, meta) = (json!({}), json!({}));
for caller in ["phone-1", "phone-2"] {
let lookup = move |name: &str| (name == "deviceid").then(|| caller.to_string());
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.header = &lookup;
assert!(
matches!(
apply_guards(req).await,
Err(OrionError::RateLimitKeyUnavailable(_))
),
"{caller} must be refused for an uncomputable key, never counted \
in a shared bucket"
);
}
}
#[tokio::test]
async fn an_empty_rate_limit_key_refuses() {
let dl = engine();
let runtime = Runtime::new()
.limiter(
Arc::new(crate::channel::LocalRateLimitBackend::new(1000, 1000)),
BackendErrorPolicy::Allow,
)
.key_logic(&dl, json!({"var": "headers.x-tenant-id"}))
.build();
let (data, meta) = (json!({}), json!({}));
let blank = |name: &str| (name == "x-tenant-id").then(|| " ".to_string());
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.header = ␣
assert!(
matches!(
apply_guards(req).await,
Err(OrionError::RateLimitKeyUnavailable(_))
),
"a blank key is as unusable as a missing one"
);
}
#[tokio::test]
async fn a_declared_custom_header_is_visible_to_key_logic() {
for transport in [Transport::HttpSync, Transport::HttpAsync, Transport::Kafka] {
let dl = engine();
let runtime = Runtime::new()
.limiter(
Arc::new(crate::channel::LocalRateLimitBackend::new(1, 1)),
BackendErrorPolicy::Allow,
)
.key_headers(&["deviceid"])
.key_logic(&dl, json!({"var": "headers.deviceid"}))
.build();
let (data, meta) = (json!({}), json!({}));
let phone = |name: &str| (name == "deviceid").then(|| "phone".to_string());
let tablet = |name: &str| (name == "deviceid").then(|| "tablet".to_string());
let mut req = request(transport, &runtime, &dl, &data, &meta);
req.header = ☎
assert!(
apply_guards(req).await.is_ok(),
"{transport:?}: first device"
);
let mut req = request(transport, &runtime, &dl, &data, &meta);
req.header = &tablet;
assert!(
apply_guards(req).await.is_ok(),
"{transport:?}: a second device must not share the first's bucket"
);
let mut req = request(transport, &runtime, &dl, &data, &meta);
req.header = ☎
assert!(
matches!(apply_guards(req).await, Err(OrionError::RateLimited(_))),
"{transport:?}: the first device's own bucket must be empty"
);
}
}
#[tokio::test]
async fn declaring_one_header_does_not_expose_the_others() {
let dl = engine();
let runtime = Runtime::new()
.limiter(
Arc::new(crate::channel::LocalRateLimitBackend::new(1000, 1000)),
BackendErrorPolicy::Allow,
)
.key_headers(&["deviceid"])
.key_logic(&dl, json!({"var": "headers.x-partner"}))
.build();
let (data, meta) = (json!({}), json!({}));
let both = |name: &str| match name {
"deviceid" => Some("phone".to_string()),
"x-partner" => Some("acme".to_string()),
_ => None,
};
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.header = &both;
assert!(
matches!(
apply_guards(req).await,
Err(OrionError::RateLimitKeyUnavailable(_))
),
"an undeclared header stays invisible even when another is declared"
);
}
#[tokio::test]
async fn redeclaring_a_builtin_header_is_harmless() {
let dl = engine();
let runtime = Runtime::new()
.limiter(
Arc::new(crate::channel::LocalRateLimitBackend::new(1, 1)),
BackendErrorPolicy::Allow,
)
.key_headers(&["x-tenant-id"])
.key_logic(&dl, json!({"var": "headers.x-tenant-id"}))
.build();
let (data, meta) = (json!({}), json!({}));
let acme = |name: &str| (name == "x-tenant-id").then(|| "acme".to_string());
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.header = &acme;
assert!(apply_guards(req).await.is_ok());
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.header = &acme;
assert!(
matches!(apply_guards(req).await, Err(OrionError::RateLimited(_))),
"the bucket must behave exactly as an undeclared built-in does"
);
}
#[test]
fn key_logic_header_paths_reads_only_static_var_nodes() {
assert_eq!(
key_logic_header_paths(&json!({"var": "headers.deviceid"})),
vec!["deviceid".to_string()]
);
assert_eq!(
key_logic_header_paths(&json!({
"cat": [{"var": "client_ip"}, ":", {"var": ["headers.X-Partner", "none"]}]
})),
vec!["x-partner".to_string()]
);
assert_eq!(
key_logic_header_paths(&json!({
"cat": [{"var": "headers.a"}, {"var": "headers.b"}, {"var": "headers.a"}]
})),
vec!["a".to_string(), "b".to_string()]
);
assert!(key_logic_header_paths(&json!({"var": "client_ip"})).is_empty());
assert!(key_logic_header_paths(&json!({"var": "headers."})).is_empty());
assert!(
key_logic_header_paths(&json!({"var": {"cat": ["headers.", {"var": "x"}]}})).is_empty(),
"a composed path must not be guessed at"
);
}
#[tokio::test]
async fn backpressure_permits_are_shared_across_transports() {
let dl = engine();
let runtime = Runtime::new().backpressure(1).build();
let (data, meta) = (json!({}), json!({}));
let held = admitted(
apply_guards(request(Transport::HttpSync, &runtime, &dl, &data, &meta))
.await
.expect("first admission"),
)
.expect("an admission, not a cache hit");
assert!(held.backpressure_permit.is_some());
for transport in [Transport::Kafka, Transport::ChannelCall] {
let verdict = apply_guards(request(transport, &runtime, &dl, &data, &meta)).await;
assert!(
matches!(verdict, Err(OrionError::ServiceUnavailable { .. })),
"{transport:?} must be shed while the permit is held"
);
}
drop(held);
assert!(
apply_guards(request(Transport::Kafka, &runtime, &dl, &data, &meta))
.await
.is_ok()
);
}
#[tokio::test]
async fn the_origin_allow_list_applies_to_http_only() {
let dl = engine();
let runtime = Runtime::new().origins(&["https://allowed.example"]).build();
let (data, meta) = (json!({}), json!({}));
for transport in [Transport::HttpSync, Transport::HttpAsync] {
let mut req = request(transport, &runtime, &dl, &data, &meta);
req.origin = Some("https://evil.example");
assert!(
matches!(apply_guards(req).await, Err(OrionError::Forbidden(_))),
"{transport:?} must refuse an unlisted origin"
);
}
for transport in [Transport::Kafka, Transport::ChannelCall] {
let mut req = request(transport, &runtime, &dl, &data, &meta);
req.origin = Some("https://evil.example");
assert!(
apply_guards(req).await.is_ok(),
"{transport:?} does not check origins"
);
}
}
#[tokio::test]
async fn the_pre_1_0_cors_spelling_cannot_produce_a_runtime() {
let stored = r#"{"cors": {"allowed_origins": ["https://allowed.example"]}}"#;
assert!(
serde_json::from_str::<crate::channel::ChannelConfig>(stored).is_err(),
"the old spelling must fail the config, not silently drop the allow-list"
);
let dl = engine();
let runtime = Runtime::new().build();
let (data, meta) = (json!({}), json!({}));
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.origin = Some("https://evil.example");
assert!(
apply_guards(req).await.is_ok(),
"a channel with no allow-list checks nothing — which is why the \
old spelling must not degrade into one"
);
}
#[tokio::test]
async fn validation_logic_applies_on_every_transport() {
let dl = engine();
let runtime = Runtime::new()
.validation(&dl, json!({"!!": {"var": "data.order_id"}}))
.build();
let bad = json!({});
let good = json!({"order_id": "ORD-1"});
let meta = json!({});
for transport in [
Transport::HttpSync,
Transport::HttpAsync,
Transport::Kafka,
Transport::ChannelCall,
] {
assert!(
matches!(
apply_guards(request(transport, &runtime, &dl, &bad, &meta)).await,
Err(OrionError::Validation { .. })
),
"{transport:?} must reject"
);
assert!(
apply_guards(request(transport, &runtime, &dl, &good, &meta))
.await
.is_ok(),
"{transport:?} must accept"
);
}
}
#[tokio::test]
async fn test_dedup_new_key_passes() {
let cfg = dedup_runtime(StubOutcome::New);
let result =
super::check_deduplication("test-channel", &cfg, IDEMPOTENCY, None, None).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_dedup_duplicate_rejected() {
let cfg = dedup_runtime(StubOutcome::Duplicate);
let result =
super::check_deduplication("test-channel", &cfg, IDEMPOTENCY, None, None).await;
assert!(matches!(result, Err(OrionError::Conflict(_))));
}
#[tokio::test]
async fn test_dedup_fails_open_on_backend_error_by_default() {
let cfg = dedup_runtime(StubOutcome::BackendError);
let result =
super::check_deduplication("test-channel", &cfg, IDEMPOTENCY, None, None).await;
assert!(result.is_ok(), "backend errors must fail open, not 409");
}
#[tokio::test]
async fn test_dedup_fails_closed_when_policy_is_deny() {
let cfg = dedup_runtime_with_policy(StubOutcome::BackendError, BackendErrorPolicy::Deny);
let result =
super::check_deduplication("test-channel", &cfg, IDEMPOTENCY, None, None).await;
assert!(
matches!(result, Err(OrionError::ServiceUnavailable { .. })),
"deny must refuse with 503"
);
}
#[tokio::test]
async fn test_deny_policy_does_not_affect_healthy_backend() {
let cfg = dedup_runtime_with_policy(StubOutcome::New, BackendErrorPolicy::Deny);
let result =
super::check_deduplication("test-channel", &cfg, IDEMPOTENCY, None, None).await;
assert!(result.is_ok());
let cfg = dedup_runtime_with_policy(StubOutcome::Duplicate, BackendErrorPolicy::Deny);
let result =
super::check_deduplication("test-channel", &cfg, IDEMPOTENCY, None, None).await;
assert!(matches!(result, Err(OrionError::Conflict(_))));
}
#[tokio::test]
async fn test_dedup_key_is_channel_scoped() {
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let cfg = Runtime::new()
.dedup(
Arc::new(CapturingDedupBackend { seen: seen.clone() }),
BackendErrorPolicy::Allow,
)
.build();
super::check_deduplication("orders", &cfg, IDEMPOTENCY, None, None)
.await
.expect("dedup check should pass");
let keys = seen.lock().expect("test lock poisoned");
assert_eq!(keys.as_slice(), ["dedup:orders:token-1"]);
}
#[tokio::test]
async fn the_kafka_record_key_is_the_fallback_idempotency_key() {
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let cfg = Runtime::new()
.dedup(
Arc::new(CapturingDedupBackend { seen: seen.clone() }),
BackendErrorPolicy::Allow,
)
.build();
super::check_deduplication("orders", &cfg, NO_HEADERS, Some("ORD-77"), None)
.await
.expect("dedup check should pass");
assert_eq!(
seen.lock().expect("test lock poisoned").as_slice(),
["dedup:orders:ORD-77"]
);
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let cfg = Runtime::new()
.dedup(
Arc::new(CapturingDedupBackend { seen: seen.clone() }),
BackendErrorPolicy::Allow,
)
.build();
super::check_deduplication("orders", &cfg, IDEMPOTENCY, Some("ORD-77"), None)
.await
.expect("dedup check should pass");
assert_eq!(
seen.lock().expect("test lock poisoned").as_slice(),
["dedup:orders:token-1"]
);
}
#[tokio::test]
async fn channel_call_is_not_deduplicated() {
let dl = engine();
let runtime = Runtime::new()
.dedup(
Arc::new(StubDedupBackend {
outcome: StubOutcome::Duplicate,
}),
BackendErrorPolicy::Allow,
)
.build();
let (data, meta) = (json!({}), json!({}));
let mut req = request(Transport::ChannelCall, &runtime, &dl, &data, &meta);
req.header = IDEMPOTENCY;
assert!(
apply_guards(req).await.is_ok(),
"channel_call must not consult the dedup store"
);
for transport in [Transport::HttpSync, Transport::HttpAsync, Transport::Kafka] {
let mut req = request(transport, &runtime, &dl, &data, &meta);
req.header = IDEMPOTENCY;
assert!(
matches!(apply_guards(req).await, Err(OrionError::Conflict(_))),
"{transport:?} must reject a duplicate"
);
}
}
#[tokio::test]
async fn a_kafka_redelivery_recognises_its_own_unsettled_claim() {
let dl = engine();
let store = Arc::new(InMemoryDedupBackend::default());
let runtime = Runtime::new()
.dedup(store.clone(), BackendErrorPolicy::Allow)
.build();
let (data, meta) = (json!({}), json!({}));
let mut req = request(Transport::Kafka, &runtime, &dl, &data, &meta);
req.dedup_key_fallback = Some("ORD-77");
req.dedup_owner = Some("kafka:orders/0/7");
let admission = admitted(apply_guards(req).await.expect("first attempt admitted"))
.expect("an admission");
admission
.dedup_claim
.expect("a claim was taken")
.release()
.await;
let mut req = request(Transport::Kafka, &runtime, &dl, &data, &meta);
req.dedup_key_fallback = Some("ORD-77");
req.dedup_owner = Some("kafka:orders/0/7");
assert!(
apply_guards(req).await.is_ok(),
"a redelivery of an uncommitted offset must be processed, not committed as a duplicate"
);
}
#[tokio::test]
async fn a_claim_left_behind_by_a_dead_attempt_does_not_suppress_the_record() {
let dl = engine();
let store = Arc::new(InMemoryDedupBackend::default());
let runtime = Runtime::new()
.dedup(store.clone(), BackendErrorPolicy::Allow)
.build();
let (data, meta) = (json!({}), json!({}));
for _ in 0..3 {
let mut req = request(Transport::Kafka, &runtime, &dl, &data, &meta);
req.dedup_key_fallback = Some("ORD-77");
req.dedup_owner = Some("kafka:orders/0/7");
let admission = admitted(apply_guards(req).await.expect("admitted"))
.expect("an admission, not a cache hit");
drop(admission);
}
}
#[tokio::test]
async fn a_confirmed_claim_suppresses_every_later_delivery() {
let dl = engine();
let store = Arc::new(InMemoryDedupBackend::default());
let runtime = Runtime::new()
.dedup(store.clone(), BackendErrorPolicy::Allow)
.build();
let (data, meta) = (json!({}), json!({}));
let mut req = request(Transport::Kafka, &runtime, &dl, &data, &meta);
req.dedup_key_fallback = Some("ORD-77");
req.dedup_owner = Some("kafka:orders/0/7");
let admission = admitted(apply_guards(req).await.expect("admitted")).expect("an admission");
admission
.dedup_claim
.expect("a claim was taken")
.confirm()
.await;
let mut req = request(Transport::Kafka, &runtime, &dl, &data, &meta);
req.dedup_key_fallback = Some("ORD-77");
req.dedup_owner = Some("kafka:orders/0/12");
assert!(
matches!(apply_guards(req).await, Err(OrionError::Conflict(_))),
"a settled key must refuse a later record carrying it"
);
let mut req = request(Transport::Kafka, &runtime, &dl, &data, &meta);
req.dedup_key_fallback = Some("ORD-77");
req.dedup_owner = Some("kafka:orders/0/7");
assert!(
matches!(apply_guards(req).await, Err(OrionError::Conflict(_))),
"a settled key must refuse a replay of the record that settled it"
);
}
#[tokio::test]
async fn two_http_requests_with_one_key_are_still_a_duplicate() {
let dl = engine();
let store = Arc::new(InMemoryDedupBackend::default());
let runtime = Runtime::new()
.dedup(store.clone(), BackendErrorPolicy::Allow)
.build();
let (data, meta) = (json!({}), json!({}));
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.header = IDEMPOTENCY;
assert!(apply_guards(req).await.is_ok(), "the first request passes");
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.header = IDEMPOTENCY;
assert!(
matches!(apply_guards(req).await, Err(OrionError::Conflict(_))),
"the second must be refused"
);
}
#[tokio::test]
async fn a_shed_request_releases_the_key_it_claimed() {
let dl = engine();
let store = Arc::new(InMemoryDedupBackend::default());
let runtime = Runtime::new()
.dedup(store.clone(), BackendErrorPolicy::Allow)
.backpressure(1)
.build();
let (data, meta) = (json!({}), json!({}));
let held = admitted(
apply_guards(request(Transport::HttpSync, &runtime, &dl, &data, &meta))
.await
.expect("first admission"),
)
.expect("an admission");
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.header = IDEMPOTENCY;
assert!(matches!(
apply_guards(req).await,
Err(OrionError::ServiceUnavailable { .. })
));
assert!(
store
.get("dedup:orders:token-1")
.await
.expect("store readable")
.is_none(),
"a shed request must leave no claim behind"
);
drop(held);
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.header = IDEMPOTENCY;
assert!(
apply_guards(req).await.is_ok(),
"the retry of a shed request must not be refused as a duplicate"
);
}
#[tokio::test]
async fn only_sync_http_reads_the_response_cache() {
let dl = engine();
let runtime = Runtime::new().cache(Arc::new(AlwaysHitCache)).build();
let (data, meta) = (json!({}), json!({}));
let verdict = apply_guards(request(Transport::HttpSync, &runtime, &dl, &data, &meta))
.await
.expect("guards pass");
assert!(matches!(verdict, GuardVerdict::CacheHit(ref body) if body.contains("cached")));
for transport in [
Transport::HttpAsync,
Transport::Kafka,
Transport::ChannelCall,
] {
let verdict = apply_guards(request(transport, &runtime, &dl, &data, &meta))
.await
.expect("guards pass");
let admission = admitted(verdict).expect("an admission, not a cache hit");
assert!(
admission.cache_store.is_none(),
"{transport:?} must neither read nor write the response cache"
);
}
}
#[tokio::test]
async fn dedup_precedes_the_cache_lookup() {
let dl = engine();
let runtime = Runtime::new()
.dedup(
Arc::new(StubDedupBackend {
outcome: StubOutcome::Duplicate,
}),
BackendErrorPolicy::Allow,
)
.cache(Arc::new(AlwaysHitCache))
.build();
let (data, meta) = (json!({}), json!({}));
let mut req = request(Transport::HttpSync, &runtime, &dl, &data, &meta);
req.header = IDEMPOTENCY;
assert!(matches!(
apply_guards(req).await,
Err(OrionError::Conflict(_))
));
}
#[tokio::test]
async fn a_cache_hit_takes_no_backpressure_permit() {
let dl = engine();
let runtime = Runtime::new()
.cache(Arc::new(AlwaysHitCache))
.backpressure(1)
.build();
let (data, meta) = (json!({}), json!({}));
for _ in 0..3 {
let verdict = apply_guards(request(Transport::HttpSync, &runtime, &dl, &data, &meta))
.await
.expect("guards pass");
assert!(matches!(verdict, GuardVerdict::CacheHit(_)));
}
let semaphore = runtime
.as_ref()
.expect("runtime")
.backpressure_semaphore
.as_ref()
.expect("semaphore");
assert_eq!(semaphore.available_permits(), 1);
}
fn cache_cfg(fields: Option<Vec<String>>) -> crate::channel::ChannelCacheConfig {
crate::channel::ChannelCacheConfig {
enabled: true,
ttl_secs: Some(60),
cache_key_fields: fields,
key_logic: None,
connector: None,
}
}
fn key(
channel: &str,
data: &serde_json::Value,
metadata: &serde_json::Value,
cfg: &crate::channel::ChannelCacheConfig,
) -> String {
super::response_cache::compute_cache_key(
channel,
data,
metadata,
cfg,
None,
&datalogic_rs::Engine::new(),
)
.expect("this request must have a cache key")
}
fn meta(
method: &str,
params: serde_json::Value,
query: serde_json::Value,
) -> serde_json::Value {
serde_json::json!({
"http_method": method,
"params": params,
"query": query,
"headers": {},
})
}
#[test]
fn test_cache_key_distinguishes_route_params() {
let data = serde_json::json!({});
let a = key(
"orders",
&data,
&meta("GET", serde_json::json!({"id": "1"}), serde_json::json!({})),
&cache_cfg(None),
);
let b = key(
"orders",
&data,
&meta("GET", serde_json::json!({"id": "2"}), serde_json::json!({})),
&cache_cfg(None),
);
assert_ne!(a, b, "different path params must not share a cache entry");
}
#[test]
fn test_cache_key_distinguishes_query_and_method() {
let data = serde_json::json!({});
let base = meta(
"GET",
serde_json::json!({}),
serde_json::json!({"page": "1"}),
);
let a = key("orders", &data, &base, &cache_cfg(None));
let b = key(
"orders",
&data,
&meta(
"GET",
serde_json::json!({}),
serde_json::json!({"page": "2"}),
),
&cache_cfg(None),
);
let c = key(
"orders",
&data,
&meta(
"POST",
serde_json::json!({}),
serde_json::json!({"page": "1"}),
),
&cache_cfg(None),
);
assert_ne!(a, b);
assert_ne!(a, c);
}
#[test]
fn test_cache_key_stable_for_identical_requests() {
let data = serde_json::json!({"order_id": 7});
let m = meta(
"GET",
serde_json::json!({"id": "1"}),
serde_json::json!({"expand": "items"}),
);
let a = key("orders", &data, &m, &cache_cfg(None));
let b = key("orders", &data, &m, &cache_cfg(None));
assert_eq!(a, b);
}
#[test]
fn documented_data_prefixed_paths_distinguish_callers() {
let m = meta("POST", serde_json::json!({}), serde_json::json!({}));
let fields = Some(vec!["data.user_id".to_string(), "data.action".to_string()]);
let alice = serde_json::json!({"user_id": "alice", "action": "balance"});
let bob = serde_json::json!({"user_id": "bob", "action": "balance"});
assert_ne!(
key("acct", &alice, &m, &cache_cfg(fields.clone())),
key("acct", &bob, &m, &cache_cfg(fields)),
"distinct users must not share a cache entry"
);
}
#[test]
fn dotted_paths_walk_into_nested_objects() {
let m = meta("POST", serde_json::json!({}), serde_json::json!({}));
let fields = Some(vec!["user.id".to_string()]);
let a = serde_json::json!({"user": {"id": 1}});
let b = serde_json::json!({"user": {"id": 2}});
assert_ne!(
key("c", &a, &m, &cache_cfg(fields.clone())),
key("c", &b, &m, &cache_cfg(fields))
);
}
#[test]
fn a_literal_dotted_payload_key_still_wins() {
let m = meta("POST", serde_json::json!({}), serde_json::json!({}));
let fields = Some(vec!["a.b".to_string()]);
let flat_1 = serde_json::json!({"a.b": 1});
let flat_2 = serde_json::json!({"a.b": 2});
assert_ne!(
key("c", &flat_1, &m, &cache_cfg(fields.clone())),
key("c", &flat_2, &m, &cache_cfg(fields.clone()))
);
let nested = serde_json::json!({"a": {"b": 1}});
assert_eq!(
key("c", &flat_1, &m, &cache_cfg(fields.clone())),
key("c", &nested, &m, &cache_cfg(fields))
);
}
#[test]
fn the_key_is_stable_across_processes() {
let m = meta(
"POST",
serde_json::json!({"id": "7"}),
serde_json::json!({"expand": "items"}),
);
let data = serde_json::json!({"order_id": 7, "nested": {"a": [1, 2, 3]}});
assert_eq!(
key("orders", &data, &m, &cache_cfg(None)),
"cache:orders:47396736ec3c2fde9455d2f9a9161e91"
);
}
#[test]
fn key_ignores_map_ordering() {
let data = serde_json::json!({});
let a = meta(
"GET",
serde_json::json!({}),
serde_json::json!({"a": "1", "b": "2"}),
);
let b = meta(
"GET",
serde_json::json!({}),
serde_json::json!({"b": "2", "a": "1"}),
);
assert_eq!(
key("c", &data, &a, &cache_cfg(None)),
key("c", &data, &b, &cache_cfg(None))
);
}
#[test]
fn framing_separates_adjacent_chunks() {
let data = serde_json::json!({});
let a = meta("GET", serde_json::json!({"ab": "c"}), serde_json::json!({}));
let b = meta("GET", serde_json::json!({"a": "bc"}), serde_json::json!({}));
assert_ne!(
key("c", &data, &a, &cache_cfg(None)),
key("c", &data, &b, &cache_cfg(None))
);
}
#[test]
fn a_payload_matching_no_declared_field_has_no_key() {
let m = meta("POST", serde_json::json!({}), serde_json::json!({}));
let fields = Some(vec!["user_id".to_string(), "action".to_string()]);
assert!(
super::response_cache::compute_cache_key(
"acct",
&serde_json::json!({"unrelated": 1}),
&m,
&cache_cfg(fields),
None,
&datalogic_rs::Engine::new(),
)
.is_none()
);
}
#[test]
fn absent_fields_are_not_silently_skipped() {
let m = meta("POST", serde_json::json!({}), serde_json::json!({}));
let fields = Some(vec!["a".to_string(), "b".to_string()]);
assert_ne!(
key(
"c",
&serde_json::json!({"a": 1}),
&m,
&cache_cfg(fields.clone())
),
key("c", &serde_json::json!({"b": 1}), &m, &cache_cfg(fields))
);
}
#[test]
fn test_cache_key_folds_route_identity_with_key_fields() {
let data = serde_json::json!({"tenant": "acme"});
let fields = Some(vec!["tenant".to_string()]);
let a = key(
"orders",
&data,
&meta("GET", serde_json::json!({"id": "1"}), serde_json::json!({})),
&cache_cfg(fields.clone()),
);
let b = key(
"orders",
&data,
&meta("GET", serde_json::json!({"id": "2"}), serde_json::json!({})),
&cache_cfg(fields),
);
assert_ne!(a, b);
}
}