mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: add payment gateway and billing plans
This commit is contained in:
@@ -3,8 +3,11 @@ use axum::http::Uri;
|
||||
|
||||
use super::super::GatewayControlDecision;
|
||||
use super::credentials::{contains_string, extract_requested_model};
|
||||
use super::GatewayControlAuthContext;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
const DAILY_QUOTA_EPSILON_USD: f64 = 0.000_000_01;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) enum GatewayLocalAuthRejection {
|
||||
InvalidApiKey,
|
||||
@@ -59,34 +62,309 @@ pub(crate) async fn request_model_local_rejection(
|
||||
let Some(auth_context) = decision.auth_context.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(allowed_models) = auth_context.allowed_models.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(requested_model) = extract_requested_model(decision, uri, headers, body) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if contains_string(allowed_models, &requested_model) {
|
||||
return Ok(None);
|
||||
}
|
||||
if model_directive_base_model_is_allowed_for_request(
|
||||
state,
|
||||
decision,
|
||||
&requested_model,
|
||||
allowed_models,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
if request_model_resolves_to_allowed_model(state, decision, &requested_model, allowed_models)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
let requested_model = extract_requested_model(decision, uri, headers, body);
|
||||
if let (Some(allowed_models), Some(requested_model)) = (
|
||||
auth_context.allowed_models.as_deref(),
|
||||
requested_model.as_deref(),
|
||||
) {
|
||||
if !contains_string(allowed_models, requested_model)
|
||||
&& !model_directive_base_model_is_allowed_for_request(
|
||||
state,
|
||||
decision,
|
||||
requested_model,
|
||||
allowed_models,
|
||||
)
|
||||
.await
|
||||
&& !request_model_resolves_to_allowed_model(
|
||||
state,
|
||||
decision,
|
||||
requested_model,
|
||||
allowed_models,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(GatewayLocalAuthRejection::ModelNotAllowed {
|
||||
model: requested_model.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(GatewayLocalAuthRejection::ModelNotAllowed {
|
||||
model: requested_model,
|
||||
}))
|
||||
balance_capacity_rejection(
|
||||
state,
|
||||
decision,
|
||||
auth_context,
|
||||
requested_model.as_deref(),
|
||||
body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn balance_capacity_rejection(
|
||||
state: &AppState,
|
||||
decision: &GatewayControlDecision,
|
||||
auth_context: &GatewayControlAuthContext,
|
||||
requested_model: Option<&str>,
|
||||
body: &Bytes,
|
||||
) -> Result<Option<GatewayLocalAuthRejection>, GatewayError> {
|
||||
if auth_context.api_key_is_standalone || auth_context.admin_bypass_limits {
|
||||
return Ok(None);
|
||||
}
|
||||
if auth_context.local_rejection.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let quota = state
|
||||
.find_user_daily_quota_availability(&auth_context.user_id)
|
||||
.await?
|
||||
.filter(|quota| quota.has_active_daily_quota);
|
||||
let wallet = state
|
||||
.read_wallet_snapshot_for_auth(
|
||||
&auth_context.user_id,
|
||||
&auth_context.api_key_id,
|
||||
auth_context.api_key_is_standalone,
|
||||
)
|
||||
.await?;
|
||||
let wallet_available_usd = wallet.as_ref().and_then(wallet_finite_available_usd);
|
||||
let wallet_is_unlimited = wallet
|
||||
.as_ref()
|
||||
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
|
||||
let (available_usd, require_cost_estimate) = match quota.as_ref() {
|
||||
Some(quota) if !quota.allow_wallet_overage => (Some(quota.remaining_usd.max(0.0)), true),
|
||||
Some(_) if wallet_is_unlimited => (None, false),
|
||||
Some(quota) => (
|
||||
Some(quota.remaining_usd.max(0.0) + wallet_available_usd.unwrap_or(0.0)),
|
||||
true,
|
||||
),
|
||||
None if wallet_is_unlimited => (None, false),
|
||||
None => (wallet_available_usd, false),
|
||||
};
|
||||
let Some(available_usd) = available_usd else {
|
||||
return Ok(None);
|
||||
};
|
||||
if available_usd <= DAILY_QUOTA_EPSILON_USD {
|
||||
return Ok(Some(GatewayLocalAuthRejection::BalanceDenied {
|
||||
remaining: Some(0.0),
|
||||
}));
|
||||
}
|
||||
let Some(requested_model) = requested_model else {
|
||||
return if require_cost_estimate {
|
||||
Ok(Some(GatewayLocalAuthRejection::BalanceDenied {
|
||||
remaining: Some(available_usd),
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
};
|
||||
let Some(estimated_cost_usd) =
|
||||
estimate_request_cost_upper_bound_usd(state, decision, requested_model, body).await?
|
||||
else {
|
||||
return if require_cost_estimate {
|
||||
Ok(Some(GatewayLocalAuthRejection::BalanceDenied {
|
||||
remaining: Some(available_usd),
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
};
|
||||
if estimated_cost_usd > available_usd + DAILY_QUOTA_EPSILON_USD {
|
||||
return Ok(Some(GatewayLocalAuthRejection::BalanceDenied {
|
||||
remaining: Some(available_usd),
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn wallet_finite_available_usd(
|
||||
wallet: &aether_data::repository::wallet::StoredWalletSnapshot,
|
||||
) -> Option<f64> {
|
||||
if !wallet.status.eq_ignore_ascii_case("active")
|
||||
|| wallet.limit_mode.eq_ignore_ascii_case("unlimited")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(wallet.balance.max(0.0) + wallet.gift_balance.max(0.0))
|
||||
}
|
||||
|
||||
async fn estimate_request_cost_upper_bound_usd(
|
||||
state: &AppState,
|
||||
decision: &GatewayControlDecision,
|
||||
requested_model: &str,
|
||||
body: &Bytes,
|
||||
) -> Result<Option<f64>, GatewayError> {
|
||||
let Some(api_format) = decision
|
||||
.auth_endpoint_signature
|
||||
.as_deref()
|
||||
.map(crate::ai_serving::normalize_api_format_alias)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let body_json = serde_json::from_slice::<serde_json::Value>(body).ok();
|
||||
let Some(input_tokens) = body_json
|
||||
.as_ref()
|
||||
.map(estimate_json_tokens)
|
||||
.filter(|value| *value > 0)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let max_output_tokens = body_json.as_ref().and_then(max_output_tokens_from_request);
|
||||
let candidates = state
|
||||
.list_minimal_candidate_selection_rows_for_api_format_and_requested_model(
|
||||
&api_format,
|
||||
requested_model,
|
||||
)
|
||||
.await?;
|
||||
let mut max_estimate = None::<f64>;
|
||||
for candidate in candidates {
|
||||
let context = state
|
||||
.data
|
||||
.find_billing_model_context_by_model_id(
|
||||
&candidate.provider_id,
|
||||
Some(&candidate.key_id),
|
||||
&candidate.model_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
let Some(context) = context else {
|
||||
continue;
|
||||
};
|
||||
let Some(estimate) = estimate_cost_from_billing_context(
|
||||
&context,
|
||||
&api_format,
|
||||
input_tokens,
|
||||
max_output_tokens,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
max_estimate = Some(max_estimate.map_or(estimate, |current| current.max(estimate)));
|
||||
}
|
||||
Ok(max_estimate.filter(|value| value.is_finite() && *value >= 0.0))
|
||||
}
|
||||
|
||||
fn estimate_cost_from_billing_context(
|
||||
context: &aether_data_contracts::repository::billing::StoredBillingModelContext,
|
||||
api_format: &str,
|
||||
input_tokens: u64,
|
||||
max_output_tokens: Option<u64>,
|
||||
) -> Option<f64> {
|
||||
if context
|
||||
.provider_billing_type
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("free_tier"))
|
||||
{
|
||||
return Some(0.0);
|
||||
}
|
||||
let price_per_request = context
|
||||
.model_price_per_request
|
||||
.or(context.default_price_per_request)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0);
|
||||
let tiered_pricing = effective_tiered_pricing(context);
|
||||
let input_price_per_1m = tiered_price_per_1m(tiered_pricing, "input_price_per_1m")
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0);
|
||||
let output_price_per_1m = tiered_price_per_1m(tiered_pricing, "output_price_per_1m")
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0);
|
||||
let output_tokens = if output_price_per_1m > 0.0 {
|
||||
max_output_tokens?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let estimate = price_per_request
|
||||
+ (input_tokens as f64 * input_price_per_1m / 1_000_000.0)
|
||||
+ (output_tokens as f64 * output_price_per_1m / 1_000_000.0);
|
||||
let rate_multiplier = rate_multiplier_for_api_format(context, api_format);
|
||||
Some(estimate * rate_multiplier)
|
||||
}
|
||||
|
||||
fn effective_tiered_pricing(
|
||||
context: &aether_data_contracts::repository::billing::StoredBillingModelContext,
|
||||
) -> Option<&serde_json::Value> {
|
||||
context
|
||||
.model_tiered_pricing
|
||||
.as_ref()
|
||||
.filter(|value| tiered_pricing_has_rates(value))
|
||||
.or(context.default_tiered_pricing.as_ref())
|
||||
}
|
||||
|
||||
fn tiered_pricing_has_rates(value: &serde_json::Value) -> bool {
|
||||
value
|
||||
.get("tiers")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|tiers| !tiers.is_empty())
|
||||
|| ["input_price_per_1m", "output_price_per_1m"]
|
||||
.iter()
|
||||
.any(|field| {
|
||||
value
|
||||
.get(*field)
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
fn rate_multiplier_for_api_format(
|
||||
context: &aether_data_contracts::repository::billing::StoredBillingModelContext,
|
||||
api_format: &str,
|
||||
) -> f64 {
|
||||
let normalized_api_format = api_format.trim().to_ascii_lowercase();
|
||||
let Some(mapping) = context
|
||||
.provider_api_key_rate_multipliers
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
else {
|
||||
return 1.0;
|
||||
};
|
||||
mapping
|
||||
.get(&normalized_api_format)
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
fn tiered_price_per_1m(tiered_pricing: Option<&serde_json::Value>, field: &str) -> Option<f64> {
|
||||
let value = tiered_pricing?;
|
||||
value
|
||||
.get(field)
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.or_else(|| {
|
||||
value
|
||||
.get("tiers")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|tier| tier.get(field).and_then(serde_json::Value::as_f64))
|
||||
.filter(|price| price.is_finite() && *price >= 0.0)
|
||||
.max_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal))
|
||||
})
|
||||
}
|
||||
|
||||
fn max_output_tokens_from_request(value: &serde_json::Value) -> Option<u64> {
|
||||
["max_tokens", "max_completion_tokens", "max_output_tokens"]
|
||||
.iter()
|
||||
.find_map(|field| value.get(*field).and_then(serde_json::Value::as_u64))
|
||||
.filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
fn estimate_json_tokens(value: &serde_json::Value) -> u64 {
|
||||
match value {
|
||||
serde_json::Value::String(text) => estimate_text_tokens(text),
|
||||
serde_json::Value::Array(items) => items
|
||||
.iter()
|
||||
.map(estimate_json_tokens)
|
||||
.fold(0u64, u64::saturating_add),
|
||||
serde_json::Value::Object(object) => object
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
estimate_text_tokens(key).saturating_add(estimate_json_tokens(value))
|
||||
})
|
||||
.fold(0u64, u64::saturating_add),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate_text_tokens(text: &str) -> u64 {
|
||||
let chars = text.chars().count() as u64;
|
||||
chars.div_ceil(4).max(1)
|
||||
}
|
||||
|
||||
async fn model_directive_base_model_is_allowed_for_request(
|
||||
@@ -200,13 +478,18 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
|
||||
use aether_data_contracts::repository::billing::StoredBillingModelContext;
|
||||
use aether_data_contracts::repository::candidate_selection::{
|
||||
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
|
||||
};
|
||||
use axum::body::Bytes;
|
||||
use axum::http::{HeaderMap, Uri};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{request_model_local_rejection, GatewayLocalAuthRejection};
|
||||
use super::{
|
||||
estimate_cost_from_billing_context, request_model_local_rejection,
|
||||
GatewayLocalAuthRejection,
|
||||
};
|
||||
use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
|
||||
use crate::data::GatewayDataState;
|
||||
use crate::AppState;
|
||||
@@ -320,6 +603,32 @@ mod tests {
|
||||
headers
|
||||
}
|
||||
|
||||
fn billing_context_with_pricing(
|
||||
default_tiered_pricing: Option<serde_json::Value>,
|
||||
model_tiered_pricing: Option<serde_json::Value>,
|
||||
rate_multipliers: Option<serde_json::Value>,
|
||||
billing_type: Option<&str>,
|
||||
) -> StoredBillingModelContext {
|
||||
StoredBillingModelContext::new(
|
||||
"provider-1".to_string(),
|
||||
billing_type.map(ToOwned::to_owned),
|
||||
Some("key-1".to_string()),
|
||||
rate_multipliers,
|
||||
Some(60),
|
||||
"global-model-1".to_string(),
|
||||
"gpt-5".to_string(),
|
||||
None,
|
||||
None,
|
||||
default_tiered_pricing,
|
||||
Some("model-1".to_string()),
|
||||
Some("gpt-5-upstream".to_string()),
|
||||
None,
|
||||
None,
|
||||
model_tiered_pricing,
|
||||
)
|
||||
.expect("billing context should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_rejection_allows_requested_model_that_resolves_to_allowed_global_model() {
|
||||
let state = state_with_model_mapping();
|
||||
@@ -392,4 +701,70 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_quota_estimate_falls_back_to_default_tiers_when_model_tiers_empty() {
|
||||
let context = billing_context_with_pricing(
|
||||
Some(json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 3.0,
|
||||
"output_price_per_1m": 15.0
|
||||
}]
|
||||
})),
|
||||
Some(json!({})),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let estimate =
|
||||
estimate_cost_from_billing_context(&context, "openai:chat", 1_000_000, Some(1_000_000))
|
||||
.expect("estimate should resolve");
|
||||
|
||||
assert_eq!(estimate, 18.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_quota_estimate_applies_provider_key_rate_multiplier() {
|
||||
let context = billing_context_with_pricing(
|
||||
Some(json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 1.0,
|
||||
"output_price_per_1m": 2.0
|
||||
}]
|
||||
})),
|
||||
None,
|
||||
Some(json!({ "openai:chat": 2.0 })),
|
||||
None,
|
||||
);
|
||||
|
||||
let estimate =
|
||||
estimate_cost_from_billing_context(&context, "openai:chat", 1_000_000, Some(1_000_000))
|
||||
.expect("estimate should resolve");
|
||||
|
||||
assert_eq!(estimate, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_quota_estimate_treats_free_tier_as_zero_cost() {
|
||||
let context = billing_context_with_pricing(
|
||||
Some(json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 3.0,
|
||||
"output_price_per_1m": 15.0
|
||||
}]
|
||||
})),
|
||||
None,
|
||||
Some(json!({ "openai:chat": 10.0 })),
|
||||
Some("free_tier"),
|
||||
);
|
||||
|
||||
let estimate =
|
||||
estimate_cost_from_billing_context(&context, "openai:chat", 1_000_000, Some(1_000_000))
|
||||
.expect("estimate should resolve");
|
||||
|
||||
assert_eq!(estimate, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,6 +485,66 @@ pub(super) fn classify_admin_basic_family_route(
|
||||
"admin:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/billing/plans" | "/api/admin/billing/plans/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"billing_manage",
|
||||
"list_plans",
|
||||
"admin:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/billing/plans" | "/api/admin/billing/plans/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"billing_manage",
|
||||
"create_plan",
|
||||
"admin:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::PUT
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/billing/plans/")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 5
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"billing_manage",
|
||||
"update_plan",
|
||||
"admin:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::DELETE
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/billing/plans/")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 5
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"billing_manage",
|
||||
"delete_plan",
|
||||
"admin:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::PATCH
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/billing/plans/")
|
||||
&& normalized_path_no_trailing.ends_with("/status")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 6
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"billing_manage",
|
||||
"set_plan_status",
|
||||
"admin:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
@@ -498,6 +558,45 @@ pub(super) fn classify_admin_basic_family_route(
|
||||
"admin:payments",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/payments/gateways/epay" | "/api/admin/payments/gateways/epay/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"payments_manage",
|
||||
"get_epay_gateway",
|
||||
"admin:payments",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::PUT
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/payments/gateways/epay" | "/api/admin/payments/gateways/epay/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"payments_manage",
|
||||
"update_epay_gateway",
|
||||
"admin:payments",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/payments/gateways/epay/test" | "/api/admin/payments/gateways/epay/test/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"payments_manage",
|
||||
"test_epay_gateway",
|
||||
"admin:payments",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/payments/orders/")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 5
|
||||
|
||||
@@ -710,6 +710,30 @@ pub(super) fn classify_admin_operations_family_route(
|
||||
"admin:users",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/users/")
|
||||
&& normalized_path_no_trailing.ends_with("/billing/entitlements")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 6
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"users_manage",
|
||||
"list_user_billing_entitlements",
|
||||
"admin:users",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& normalized_path_no_trailing.starts_with("/api/admin/users/")
|
||||
&& normalized_path_no_trailing.ends_with("/billing/grant-plan")
|
||||
&& normalized_path_no_trailing.matches('/').count() == 6
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"users_manage",
|
||||
"grant_user_billing_plan",
|
||||
"admin:users",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& normalized_path.starts_with("/api/admin/users/")
|
||||
&& normalized_path.ends_with("/sessions")
|
||||
|
||||
@@ -316,6 +316,7 @@ pub(super) fn classify_public_support_route(
|
||||
| "/api/wallet/flow"
|
||||
| "/api/wallet/today-cost"
|
||||
| "/api/wallet/recharge"
|
||||
| "/api/wallet/recharge/options"
|
||||
| "/api/wallet/refunds"
|
||||
)
|
||||
{
|
||||
@@ -325,6 +326,7 @@ pub(super) fn classify_public_support_route(
|
||||
"/api/wallet/flow" => "flow",
|
||||
"/api/wallet/today-cost" => "today_cost",
|
||||
"/api/wallet/recharge" => "list_recharge_orders",
|
||||
"/api/wallet/recharge/options" => "recharge_options",
|
||||
"/api/wallet/refunds" => "list_refunds",
|
||||
_ => "balance",
|
||||
};
|
||||
@@ -374,6 +376,42 @@ pub(super) fn classify_public_support_route(
|
||||
"user:wallet",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/billing/plans" | "/api/billing/plans/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"billing",
|
||||
"plans",
|
||||
"public:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/billing/entitlements" | "/api/billing/entitlements/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"billing",
|
||||
"entitlements",
|
||||
"user:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& has_single_nested_suffix_after_prefix(normalized_path, "/api/billing/plans/", "checkout")
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"billing",
|
||||
"plan_checkout",
|
||||
"user:billing",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& has_single_segment_after_prefix(normalized_path, "/api/payment/callback/")
|
||||
{
|
||||
@@ -384,6 +422,32 @@ pub(super) fn classify_public_support_route(
|
||||
"public:payment",
|
||||
false,
|
||||
))
|
||||
} else if matches!(method, &http::Method::GET | &http::Method::POST)
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/payment/epay/notify" | "/api/payment/epay/notify/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"payment_callback",
|
||||
"epay_notify",
|
||||
"public:payment",
|
||||
false,
|
||||
))
|
||||
} else if matches!(method, &http::Method::GET | &http::Method::POST)
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/payment/epay/return" | "/api/payment/epay/return/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"public_support",
|
||||
"payment_callback",
|
||||
"epay_return",
|
||||
"public:payment",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use http::Uri;
|
||||
|
||||
use super::{classify_control_route, headers};
|
||||
use super::{classify_control_route, headers, GatewayPublicRequestContext};
|
||||
use crate::handlers::shared::local_proxy_route_requires_buffered_body;
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_billing_presets_as_admin_proxy_route() {
|
||||
@@ -121,3 +122,84 @@ fn classifies_admin_billing_collector_routes_as_admin_proxy_route() {
|
||||
Some("admin:billing")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_billing_plan_routes_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
let list_uri: Uri = "/api/admin/billing/plans"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let list = classify_control_route(&http::Method::GET, &list_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(list.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(list.route_family.as_deref(), Some("billing_manage"));
|
||||
assert_eq!(list.route_kind.as_deref(), Some("list_plans"));
|
||||
assert_eq!(
|
||||
list.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:billing")
|
||||
);
|
||||
|
||||
let create_uri: Uri = "/api/admin/billing/plans"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let create = classify_control_route(&http::Method::POST, &create_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(create.route_family.as_deref(), Some("billing_manage"));
|
||||
assert_eq!(create.route_kind.as_deref(), Some("create_plan"));
|
||||
|
||||
let update_uri: Uri = "/api/admin/billing/plans/plan-1"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let update = classify_control_route(&http::Method::PUT, &update_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(update.route_family.as_deref(), Some("billing_manage"));
|
||||
assert_eq!(update.route_kind.as_deref(), Some("update_plan"));
|
||||
|
||||
let status_uri: Uri = "/api/admin/billing/plans/plan-1/status"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let status = classify_control_route(&http::Method::PATCH, &status_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(status.route_family.as_deref(), Some("billing_manage"));
|
||||
assert_eq!(status.route_kind.as_deref(), Some("set_plan_status"));
|
||||
|
||||
let delete_uri: Uri = "/api/admin/billing/plans/plan-1"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let delete = classify_control_route(&http::Method::DELETE, &delete_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(delete.route_family.as_deref(), Some("billing_manage"));
|
||||
assert_eq!(delete.route_kind.as_deref(), Some("delete_plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_billing_plan_write_routes_buffer_request_body() {
|
||||
let headers = headers(&[]);
|
||||
let routes = [
|
||||
(http::Method::POST, "/api/admin/billing/plans"),
|
||||
(http::Method::PUT, "/api/admin/billing/plans/plan-1"),
|
||||
(
|
||||
http::Method::PATCH,
|
||||
"/api/admin/billing/plans/plan-1/status",
|
||||
),
|
||||
];
|
||||
|
||||
for (method, path) in routes {
|
||||
let uri: Uri = path.parse().expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&method, &uri, &headers).expect("route should classify");
|
||||
let context = GatewayPublicRequestContext::from_request_parts(
|
||||
"trace-billing-plan-write",
|
||||
&method,
|
||||
&uri,
|
||||
&headers,
|
||||
Some(decision),
|
||||
);
|
||||
|
||||
assert!(
|
||||
local_proxy_route_requires_buffered_body(&context),
|
||||
"{method} {path} should buffer request body"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use http::Uri;
|
||||
|
||||
use super::{classify_control_route, headers};
|
||||
use super::{classify_control_route, headers, GatewayPublicRequestContext};
|
||||
use crate::handlers::shared::local_proxy_route_requires_buffered_body;
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_payments_list_orders_as_admin_proxy_route() {
|
||||
@@ -205,3 +206,57 @@ fn classifies_admin_payments_redeem_code_routes_as_admin_proxy_route() {
|
||||
Some("delete_redeem_code_batch")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_epay_gateway_routes_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
for (method, uri, route_kind) in [
|
||||
(
|
||||
http::Method::GET,
|
||||
"/api/admin/payments/gateways/epay",
|
||||
"get_epay_gateway",
|
||||
),
|
||||
(
|
||||
http::Method::PUT,
|
||||
"/api/admin/payments/gateways/epay",
|
||||
"update_epay_gateway",
|
||||
),
|
||||
(
|
||||
http::Method::POST,
|
||||
"/api/admin/payments/gateways/epay/test",
|
||||
"test_epay_gateway",
|
||||
),
|
||||
] {
|
||||
let uri: Uri = uri.parse().expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&method, &uri, &headers).expect("route should classify");
|
||||
|
||||
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(decision.route_family.as_deref(), Some("payments_manage"));
|
||||
assert_eq!(decision.route_kind.as_deref(), Some(route_kind));
|
||||
assert_eq!(
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:payments")
|
||||
);
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_epay_gateway_update_buffers_request_body() {
|
||||
let headers = headers(&[]);
|
||||
let uri: Uri = "/api/admin/payments/gateways/epay"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&http::Method::PUT, &uri, &headers).expect("route should classify");
|
||||
let context = GatewayPublicRequestContext::from_request_parts(
|
||||
"trace-epay-gateway-update",
|
||||
&http::Method::PUT,
|
||||
&uri,
|
||||
&headers,
|
||||
Some(decision),
|
||||
);
|
||||
|
||||
assert!(local_proxy_route_requires_buffered_body(&context));
|
||||
}
|
||||
|
||||
@@ -70,6 +70,49 @@ fn classifies_admin_user_batch_routes_as_admin_proxy_route() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_user_billing_routes_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
let entitlements_uri: Uri = "/api/admin/users/user-1/billing/entitlements"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let entitlements = classify_control_route(&http::Method::GET, &entitlements_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(entitlements.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(entitlements.route_family.as_deref(), Some("users_manage"));
|
||||
assert_eq!(
|
||||
entitlements.route_kind.as_deref(),
|
||||
Some("list_user_billing_entitlements")
|
||||
);
|
||||
assert_eq!(
|
||||
entitlements.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:users")
|
||||
);
|
||||
|
||||
let grant_uri: Uri = "/api/admin/users/user-1/billing/grant-plan"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let grant = classify_control_route(&http::Method::POST, &grant_uri, &headers)
|
||||
.expect("route should classify");
|
||||
assert_eq!(grant.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(grant.route_family.as_deref(), Some("users_manage"));
|
||||
assert_eq!(grant.route_kind.as_deref(), Some("grant_user_billing_plan"));
|
||||
assert_eq!(
|
||||
grant.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:users")
|
||||
);
|
||||
|
||||
let context = GatewayPublicRequestContext::from_request_parts(
|
||||
"trace-user-billing-grant",
|
||||
&http::Method::POST,
|
||||
&grant_uri,
|
||||
&headers,
|
||||
Some(grant),
|
||||
);
|
||||
assert!(local_proxy_route_requires_buffered_body(&context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_user_group_routes_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
@@ -496,6 +496,114 @@ fn classifies_payment_callback_as_public_support_route() {
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_epay_callback_routes_as_public_support_route() {
|
||||
let headers = headers(&[]);
|
||||
for (method, uri, route_kind) in [
|
||||
(http::Method::GET, "/api/payment/epay/notify", "epay_notify"),
|
||||
(
|
||||
http::Method::POST,
|
||||
"/api/payment/epay/notify",
|
||||
"epay_notify",
|
||||
),
|
||||
(http::Method::GET, "/api/payment/epay/return", "epay_return"),
|
||||
(
|
||||
http::Method::POST,
|
||||
"/api/payment/epay/return",
|
||||
"epay_return",
|
||||
),
|
||||
] {
|
||||
let uri: Uri = uri.parse().expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&method, &uri, &headers).expect("route should classify");
|
||||
|
||||
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
|
||||
assert_eq!(decision.route_family.as_deref(), Some("payment_callback"));
|
||||
assert_eq!(decision.route_kind.as_deref(), Some(route_kind));
|
||||
assert_eq!(
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
Some("public:payment")
|
||||
);
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epay_post_callback_routes_buffer_request_body() {
|
||||
let headers = headers(&[]);
|
||||
for path in ["/api/payment/epay/notify", "/api/payment/epay/return"] {
|
||||
let uri: Uri = path.parse().expect("uri should parse");
|
||||
let decision = classify_control_route(&http::Method::POST, &uri, &headers)
|
||||
.expect("route should classify");
|
||||
let context = GatewayPublicRequestContext::from_request_parts(
|
||||
"trace-epay-callback",
|
||||
&http::Method::POST,
|
||||
&uri,
|
||||
&headers,
|
||||
Some(decision),
|
||||
);
|
||||
|
||||
assert!(
|
||||
local_proxy_route_requires_buffered_body(&context),
|
||||
"POST {path} should buffer request body"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_billing_plan_routes_as_public_support_routes() {
|
||||
let headers = headers(&[]);
|
||||
for (method, uri, route_kind, signature) in [
|
||||
(
|
||||
http::Method::GET,
|
||||
"/api/billing/plans",
|
||||
"plans",
|
||||
"public:billing",
|
||||
),
|
||||
(
|
||||
http::Method::POST,
|
||||
"/api/billing/plans/plan-1/checkout",
|
||||
"plan_checkout",
|
||||
"user:billing",
|
||||
),
|
||||
(
|
||||
http::Method::GET,
|
||||
"/api/billing/entitlements",
|
||||
"entitlements",
|
||||
"user:billing",
|
||||
),
|
||||
] {
|
||||
let uri: Uri = uri.parse().expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&method, &uri, &headers).expect("route should classify");
|
||||
|
||||
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
|
||||
assert_eq!(decision.route_family.as_deref(), Some("billing"));
|
||||
assert_eq!(decision.route_kind.as_deref(), Some(route_kind));
|
||||
assert_eq!(decision.auth_endpoint_signature.as_deref(), Some(signature));
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_plan_checkout_buffers_request_body() {
|
||||
let headers = headers(&[]);
|
||||
let uri: Uri = "/api/billing/plans/plan-1/checkout"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&http::Method::POST, &uri, &headers).expect("route should classify");
|
||||
let context = GatewayPublicRequestContext::from_request_parts(
|
||||
"trace-billing-checkout",
|
||||
&http::Method::POST,
|
||||
&uri,
|
||||
&headers,
|
||||
Some(decision),
|
||||
);
|
||||
|
||||
assert!(local_proxy_route_requires_buffered_body(&context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_public_catalog_providers_as_public_support_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
Reference in New Issue
Block a user