feat: add payment gateway and billing plans

This commit is contained in:
Entropy.Xu
2026-05-13 01:18:38 +08:00
parent 0fa97595bf
commit 10285c5eb9
97 changed files with 14797 additions and 404 deletions

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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")

View File

@@ -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,

View File

@@ -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"
);
}
}

View File

@@ -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));
}

View File

@@ -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(&[]);

View File

@@ -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(&[]);