Files
Aether/apps/aether-gateway/src/handlers/admin/billing/payments/gateways.rs
T

842 lines
32 KiB
Rust
Raw Normal View History

2026-05-13 01:18:38 +08:00
use super::{
build_admin_payments_backend_unavailable_response, build_admin_payments_bad_request_response,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::shared::{
normalize_payment_callback_base_url, normalize_payment_currency, normalize_payment_https_url,
payment_gateway_allow_user_refund, payment_gateway_channels_config_json,
payment_gateway_channels_json, payment_gateway_config_json, payment_gateway_refund_enabled,
payment_gateway_secret_is_legacy_unbound, payment_gateway_secret_keys_json,
PaymentGatewaySecretBinding,
};
2026-05-13 01:18:38 +08:00
use crate::{GatewayError, LocalMutationOutcome};
use aether_data_contracts::repository::billing::{
PaymentGatewayConfigCasWriteInput, PaymentGatewayConfigWriteInput,
};
2026-05-13 01:18:38 +08:00
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
2026-05-13 01:18:38 +08:00
const PAYMENT_GATEWAY_CONFIG_CAS_MAX_ATTEMPTS: usize = 8;
#[derive(Deserialize)]
struct PaymentGatewayConfigRequest {
2026-05-13 01:18:38 +08:00
#[serde(default)]
enabled: bool,
#[serde(default)]
2026-05-13 01:18:38 +08:00
endpoint_url: String,
#[serde(default)]
callback_base_url: Option<String>,
#[serde(default)]
2026-05-13 01:18:38 +08:00
merchant_id: String,
#[serde(default)]
merchant_key: Option<String>,
#[serde(default = "default_pay_currency")]
pay_currency: String,
#[serde(default = "default_usd_exchange_rate")]
usd_exchange_rate: f64,
#[serde(default = "default_min_recharge_usd")]
min_recharge_usd: f64,
#[serde(default = "default_channels")]
channels: Value,
#[serde(default)]
refund_enabled: bool,
#[serde(default)]
allow_user_refund: bool,
#[serde(default)]
config: Value,
#[serde(default)]
secrets: Value,
2026-05-13 01:18:38 +08:00
}
fn default_pay_currency() -> String {
"CNY".to_string()
}
fn default_usd_exchange_rate() -> f64 {
7.2
}
fn default_min_recharge_usd() -> f64 {
1.0
}
fn build_payment_gateway_conflict_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::CONFLICT,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
fn default_channels() -> Value {
2026-05-13 01:18:38 +08:00
json!([
{"channel": "alipay", "display_name": "支付宝", "fee_rate": 0.0},
{"channel": "wxpay", "display_name": "微信支付", "fee_rate": 0.0}
2026-05-13 01:18:38 +08:00
])
}
fn normalize_text(value: impl Into<String>, field: &str, max_len: usize) -> Result<String, String> {
let value = value.into();
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(format!("{field} must not be empty"));
}
if trimmed.chars().count() > max_len {
return Err(format!("{field} exceeds maximum length {max_len}"));
}
Ok(trimmed.to_string())
}
fn normalize_optional_text(
value: Option<String>,
max_len: usize,
) -> Result<Option<String>, String> {
let Some(value) = value else {
return Ok(None);
};
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.chars().count() > max_len {
return Err(format!("field exceeds maximum length {max_len}"));
}
Ok(Some(trimmed.to_string()))
}
fn supported_payment_gateway_provider(provider: &str) -> bool {
matches!(provider, "epay" | "alipay" | "wxpay" | "stripe")
}
fn admin_payment_gateway_provider_from_path(path: &str) -> Option<String> {
let trimmed = path.trim_end_matches('/');
let provider = trimmed
.strip_prefix("/api/admin/payments/gateways/")?
.strip_suffix("/test")
.unwrap_or_else(|| {
trimmed
.strip_prefix("/api/admin/payments/gateways/")
.unwrap_or("")
})
.trim()
.to_ascii_lowercase();
if provider.is_empty()
|| provider.contains('/')
|| !supported_payment_gateway_provider(&provider)
{
return None;
}
Some(provider)
}
fn resolve_admin_payment_gateway_provider(path: &str, route_kind: &str) -> Option<String> {
match route_kind {
"get_epay_gateway" | "update_epay_gateway" | "test_epay_gateway" => {
Some("epay".to_string())
}
"get_payment_gateway" | "update_payment_gateway" | "test_payment_gateway" => {
admin_payment_gateway_provider_from_path(path)
}
_ => None,
}
}
fn default_provider_channels(provider: &str) -> Value {
match provider {
"epay" => default_channels(),
"alipay" => json!([{"channel": "alipay", "display_name": "支付宝官方", "fee_rate": 0.0}]),
"wxpay" => json!([
{"channel": "native", "display_name": "微信 Native", "fee_rate": 0.0},
{"channel": "h5", "display_name": "微信 H5", "fee_rate": 0.0}
]),
"stripe" => json!([
{"channel": "card", "display_name": "Card", "fee_rate": 0.0},
{"channel": "alipay", "display_name": "Alipay", "fee_rate": 0.0},
{"channel": "wechat_pay", "display_name": "WeChat Pay", "fee_rate": 0.0},
{"channel": "link", "display_name": "Link", "fee_rate": 0.0}
]),
_ => json!([]),
}
}
fn split_gateway_channels_config(
record: &aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
) -> (Value, Value, Value, bool, bool) {
(
payment_gateway_channels_json(&record.channels_json),
payment_gateway_config_json(&record.channels_json),
payment_gateway_secret_keys_json(&record.channels_json),
payment_gateway_refund_enabled(&record.channels_json),
payment_gateway_allow_user_refund(&record.channels_json),
)
}
2026-05-13 01:18:38 +08:00
fn gateway_config_payload(
record: aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
) -> Value {
let (channels, config, secret_keys, refund_enabled, allow_user_refund) =
split_gateway_channels_config(&record);
2026-05-13 01:18:38 +08:00
json!({
"provider": record.provider,
"enabled": record.enabled,
"endpoint_url": record.endpoint_url,
"callback_base_url": record.callback_base_url,
"merchant_id": record.merchant_id,
"has_secret": record.merchant_key_encrypted.as_deref().is_some_and(|value| !value.trim().is_empty()),
"has_secret_keys": secret_keys,
2026-05-13 01:18:38 +08:00
"pay_currency": record.pay_currency,
"usd_exchange_rate": record.usd_exchange_rate,
"min_recharge_usd": record.min_recharge_usd,
"channels": channels,
"refund_enabled": refund_enabled,
"allow_user_refund": allow_user_refund,
"config": config,
2026-05-13 01:18:38 +08:00
"created_at": record.created_at_unix_secs,
"updated_at": record.updated_at_unix_secs,
})
}
fn gateway_config_not_found_payload(provider: &str) -> Value {
json!({
"provider": provider,
"enabled": false,
"has_secret": false,
"has_secret_keys": [],
"channels": default_provider_channels(provider),
"refund_enabled": false,
"allow_user_refund": false,
"config": {},
})
}
fn normalize_gateway_channel_fee_rate(value: Option<&Value>, index: usize) -> Result<f64, String> {
let Some(value) = value else {
return Ok(0.0);
};
let fee_rate = match value {
Value::Null => 0.0,
Value::Number(number) => number
.as_f64()
.ok_or_else(|| format!("channels[{index}].fee_rate must be a number"))?,
Value::String(value) => value
.trim()
.parse::<f64>()
.map_err(|_| format!("channels[{index}].fee_rate must be a number"))?,
_ => return Err(format!("channels[{index}].fee_rate must be a number")),
};
if !fee_rate.is_finite() || fee_rate < 0.0 {
return Err(format!("channels[{index}].fee_rate must be non-negative"));
}
Ok(fee_rate)
}
fn normalize_gateway_channels(provider: &str, channels: Value) -> Result<Value, String> {
if channels.is_null() {
return Ok(default_provider_channels(provider));
}
let Some(items) = channels.as_array() else {
return Err("channels must be an array".to_string());
};
if items.is_empty() {
return Ok(default_provider_channels(provider));
}
let normalized = items
.iter()
.enumerate()
.map(|(index, item)| {
let Some(object) = item.as_object() else {
return Err(format!("channels[{index}] must be an object"));
};
let channel = object
.get("channel")
.or_else(|| object.get("type"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| format!("channels[{index}].channel must not be empty"))?;
let display_name = object
.get("display_name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(channel);
let fee_rate = normalize_gateway_channel_fee_rate(object.get("fee_rate"), index)?;
Ok(json!({
"channel": channel,
"display_name": display_name,
"fee_rate": fee_rate,
}))
})
.collect::<Result<Vec<_>, String>>()?;
Ok(Value::Array(normalized))
}
fn normalize_config_object(config: Value) -> Result<Value, String> {
if config.is_null() {
return Ok(json!({}));
}
if config.is_object() {
return Ok(config);
}
Err("config must be an object".to_string())
}
fn merge_gateway_secret_maps(
existing_plaintext: Option<&str>,
updates: serde_json::Map<String, Value>,
) -> Result<serde_json::Map<String, Value>, &'static str> {
let mut merged = match existing_plaintext {
Some(plaintext) => serde_json::from_str::<Value>(plaintext)
.ok()
.and_then(|value| value.as_object().cloned())
.ok_or("existing gateway secrets have invalid format")?,
None => serde_json::Map::new(),
};
merged.extend(updates);
Ok(merged)
}
/// A legacy gateway ciphertext has no authenticated destination (or only the
/// provider in v2). Reusing it while changing endpoint/merchant would carry
/// an unknown credential into a different payment account. Require the
/// administrator to provide a replacement secret in that case.
fn legacy_secret_reuse_requires_reentry(
existing: Option<&aether_data_contracts::repository::billing::PaymentGatewayConfigRecord>,
requested_binding: &PaymentGatewaySecretBinding,
) -> bool {
let Some(record) = existing else {
return false;
};
let Some(ciphertext) = record.merchant_key_encrypted.as_deref() else {
return false;
};
if !payment_gateway_secret_is_legacy_unbound(ciphertext) {
return false;
}
// An invalid historical binding cannot establish that the legacy value
// belongs to the requested destination, so fail closed as well.
PaymentGatewaySecretBinding::from_record(record)
.map(|stored_binding| stored_binding != requested_binding.clone())
.unwrap_or(true)
}
fn encrypted_gateway_secret(
state: &AdminAppState<'_>,
binding: &PaymentGatewaySecretBinding,
payload: &PaymentGatewayConfigRequest,
existing: Option<&aether_data_contracts::repository::billing::PaymentGatewayConfigRecord>,
) -> Result<(Option<String>, Vec<Value>), Response<Body>> {
let provider = binding.provider.as_str();
let decrypt_existing = || {
if legacy_secret_reuse_requires_reentry(existing, binding) {
return Err(build_admin_payments_bad_request_response(
"endpoint_url or merchant_id changed; re-enter the gateway secret",
));
}
existing
.and_then(|record| record.merchant_key_encrypted.as_deref())
.map(|ciphertext| {
crate::handlers::shared::open_payment_gateway_secret(
state.app(), binding, ciphertext,
)
.map(|projection| projection.plaintext)
.map_err(|_| {
build_admin_payments_backend_unavailable_response(
"existing gateway secrets are not valid for the requested destination; re-enter the secret",
)
})
})
.transpose()
};
let secret_plaintext = if provider == "epay" {
let supplied = payload
.merchant_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if supplied.is_none() {
decrypt_existing()?;
}
supplied
} else {
let Some(secrets) = payload.secrets.as_object() else {
return if payload.secrets.is_null() {
decrypt_existing()?;
Ok((None, existing_gateway_secret_keys(existing)))
} else {
Err(build_admin_payments_bad_request_response(
"secrets must be an object",
))
};
};
let updates = secrets
.iter()
.filter_map(|(key, value)| {
let value = value.as_str()?.trim();
(!key.trim().is_empty() && !value.is_empty())
.then(|| (key.trim().to_string(), Value::String(value.to_string())))
})
.collect::<serde_json::Map<_, _>>();
if updates.is_empty() {
decrypt_existing()?;
return Ok((None, existing_gateway_secret_keys(existing)));
}
let existing_plaintext = decrypt_existing()?;
let merged = match merge_gateway_secret_maps(existing_plaintext.as_deref(), updates) {
Ok(value) => value,
Err(detail) => {
return Err(build_admin_payments_backend_unavailable_response(detail));
}
};
Some(Value::Object(merged).to_string())
};
let Some(secret_plaintext) = secret_plaintext else {
return Ok((None, existing_gateway_secret_keys(existing)));
};
let encrypted = crate::handlers::shared::seal_payment_gateway_secret(
state.app(),
binding,
&secret_plaintext,
)
.map_err(build_admin_payments_backend_unavailable_response)?;
let secret_keys = if provider == "epay" {
Vec::new()
} else {
let mut keys = serde_json::from_str::<Value>(&secret_plaintext)
.ok()
.and_then(|value| value.as_object().cloned())
.unwrap_or_default()
.into_iter()
.map(|(key, _)| Value::String(key))
.collect::<Vec<_>>();
keys.sort_by(|left, right| left.as_str().cmp(&right.as_str()));
keys
};
Ok((Some(encrypted), secret_keys))
}
fn existing_gateway_secret_keys(
record: Option<&aether_data_contracts::repository::billing::PaymentGatewayConfigRecord>,
) -> Vec<Value> {
let Some(record) = record else {
return Vec::new();
};
let (_, _, secret_keys, _, _) = split_gateway_channels_config(record);
secret_keys
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter(|value| value.as_str().is_some_and(|item| !item.trim().is_empty()))
.collect()
}
2026-05-13 01:18:38 +08:00
pub(super) async fn maybe_build_local_admin_payment_gateways_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
2026-05-13 01:18:38 +08:00
request_body: Option<&axum::body::Bytes>,
route_kind: Option<&str>,
) -> Result<Option<Response<Body>>, GatewayError> {
match route_kind {
Some("get_epay_gateway") | Some("get_payment_gateway") => {
let Some(provider) = resolve_admin_payment_gateway_provider(
request_context.path(),
route_kind.expect("matched payment gateway route kind"),
) else {
return Ok(Some(build_admin_payments_bad_request_response(
"unsupported payment gateway provider",
)));
};
let record = state.app().find_payment_gateway_config(&provider).await?;
let payload = record
.map(gateway_config_payload)
.unwrap_or_else(|| gateway_config_not_found_payload(&provider));
2026-05-13 01:18:38 +08:00
Ok(Some(Json(payload).into_response()))
}
Some("update_epay_gateway") | Some("update_payment_gateway") => {
let Some(provider) = resolve_admin_payment_gateway_provider(
request_context.path(),
route_kind.expect("matched payment gateway route kind"),
) else {
return Ok(Some(build_admin_payments_bad_request_response(
"unsupported payment gateway provider",
)));
};
2026-05-13 01:18:38 +08:00
let Some(body) = request_body else {
return Ok(Some(build_admin_payments_bad_request_response(
"缺少请求体",
)));
};
let payload = match serde_json::from_slice::<PaymentGatewayConfigRequest>(body) {
2026-05-13 01:18:38 +08:00
Ok(value) => value,
Err(_) => {
return Ok(Some(build_admin_payments_bad_request_response(
"输入验证失败",
)))
}
};
if !payload.usd_exchange_rate.is_finite() || payload.usd_exchange_rate <= 0.0 {
return Ok(Some(build_admin_payments_bad_request_response(
"usd_exchange_rate must be positive",
)));
}
if !payload.min_recharge_usd.is_finite() || payload.min_recharge_usd <= 0.0 {
return Ok(Some(build_admin_payments_bad_request_response(
"min_recharge_usd must be positive",
)));
}
let endpoint_url = if provider == "epay" {
match normalize_text(payload.endpoint_url.clone(), "endpoint_url", 512) {
Ok(value) => match normalize_payment_https_url(&value, "endpoint_url") {
Ok(value) => value,
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
},
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
}
} else {
match normalize_optional_text(Some(payload.endpoint_url.clone()), 512) {
Ok(Some(value)) => match normalize_payment_https_url(&value, "endpoint_url") {
Ok(value) => value,
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
},
Ok(None) => String::new(),
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
}
2026-05-13 01:18:38 +08:00
};
let callback_base_url =
match normalize_optional_text(payload.callback_base_url.clone(), 512) {
Ok(Some(value)) => match normalize_payment_callback_base_url(&value) {
Ok(value) => Some(value),
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
},
Ok(None) => None,
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
};
let merchant_id = if provider == "epay" {
match normalize_text(payload.merchant_id.clone(), "merchant_id", 128) {
Ok(value) => value,
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
}
} else {
match normalize_optional_text(Some(payload.merchant_id.clone()), 128) {
Ok(value) => value.unwrap_or_default(),
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
}
2026-05-13 01:18:38 +08:00
};
let pay_currency =
match normalize_payment_currency(&payload.pay_currency, "pay_currency") {
Ok(value) => value,
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
};
let config = match normalize_config_object(payload.config.clone()) {
2026-05-13 01:18:38 +08:00
Ok(value) => value,
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
};
let channels = match normalize_gateway_channels(&provider, payload.channels.clone()) {
Ok(value) => value,
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
};
let refund_enabled = payload.refund_enabled;
let allow_user_refund = refund_enabled && payload.allow_user_refund;
let binding =
match PaymentGatewaySecretBinding::new(&provider, &endpoint_url, &merchant_id) {
Ok(value) => value,
Err(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)))
}
};
let mut existing_record = state.app().find_payment_gateway_config(&provider).await?;
let expected_existing = existing_record.is_some();
for _ in 0..PAYMENT_GATEWAY_CONFIG_CAS_MAX_ATTEMPTS {
if expected_existing && existing_record.is_none() {
return Ok(Some(build_payment_gateway_conflict_response(
"payment gateway config was removed concurrently",
)));
}
let (merchant_key_encrypted, secret_keys) = match encrypted_gateway_secret(
state,
&binding,
&payload,
existing_record.as_ref(),
) {
Ok(value) => value,
Err(response) => return Ok(Some(response)),
};
let channels_json = payment_gateway_channels_config_json(
channels.clone(),
config.clone(),
Value::Array(secret_keys),
refund_enabled,
allow_user_refund,
);
let mutation = PaymentGatewayConfigCasWriteInput {
input: PaymentGatewayConfigWriteInput {
provider: provider.clone(),
enabled: payload.enabled,
endpoint_url: endpoint_url.clone(),
callback_base_url: callback_base_url.clone(),
merchant_id: merchant_id.clone(),
preserve_existing_secret: merchant_key_encrypted.is_none(),
merchant_key_encrypted,
pay_currency: pay_currency.clone(),
usd_exchange_rate: payload.usd_exchange_rate,
min_recharge_usd: payload.min_recharge_usd,
channels_json,
},
expected_existing,
expected_merchant_key_encrypted: existing_record
.as_ref()
.and_then(|record| record.merchant_key_encrypted.clone()),
};
match state
.app()
.compare_and_swap_payment_gateway_config(&mutation)
.await?
{
LocalMutationOutcome::Applied(record) => {
return Ok(Some(Json(gateway_config_payload(record)).into_response()));
}
LocalMutationOutcome::NotFound if !expected_existing => {
return Ok(Some(build_payment_gateway_conflict_response(
"payment gateway config was created concurrently",
)));
}
LocalMutationOutcome::NotFound => {
existing_record =
state.app().find_payment_gateway_config(&provider).await?;
}
LocalMutationOutcome::Invalid(detail) => {
return Ok(Some(build_admin_payments_bad_request_response(detail)));
}
LocalMutationOutcome::Unavailable => {
return Ok(Some(build_admin_payments_backend_unavailable_response(
"payment gateway config backend unavailable",
)));
}
2026-05-13 01:18:38 +08:00
}
}
Ok(Some(build_payment_gateway_conflict_response(
"payment gateway config changed too frequently; retry the request",
)))
2026-05-13 01:18:38 +08:00
}
Some("test_epay_gateway") | Some("test_payment_gateway") => {
let Some(provider) = resolve_admin_payment_gateway_provider(
request_context.path(),
route_kind.expect("matched payment gateway route kind"),
) else {
return Ok(Some(build_admin_payments_bad_request_response(
"unsupported payment gateway provider",
)));
};
let status = state.app().find_payment_gateway_config(&provider).await?;
2026-05-13 01:18:38 +08:00
let ok = status
.as_ref()
.is_some_and(|record| record.enabled && record.merchant_key_encrypted.is_some());
Ok(Some(
(
if ok {
http::StatusCode::OK
} else {
http::StatusCode::BAD_REQUEST
},
Json(json!({"ok": ok, "provider": provider})),
2026-05-13 01:18:38 +08:00
)
.into_response(),
))
}
_ => Ok(None),
}
}
#[cfg(test)]
mod tests {
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
use aether_data_contracts::repository::billing::PaymentGatewayConfigRecord;
use serde_json::{json, Value};
use super::{
legacy_secret_reuse_requires_reentry, merge_gateway_secret_maps,
resolve_admin_payment_gateway_provider,
};
use crate::handlers::shared::PaymentGatewaySecretBinding;
fn gateway_record(
endpoint_url: &str,
merchant_id: &str,
merchant_key_encrypted: Option<String>,
) -> PaymentGatewayConfigRecord {
PaymentGatewayConfigRecord {
provider: "stripe".to_string(),
enabled: true,
endpoint_url: endpoint_url.to_string(),
callback_base_url: None,
merchant_id: merchant_id.to_string(),
merchant_key_encrypted,
pay_currency: "USD".to_string(),
usd_exchange_rate: 1.0,
min_recharge_usd: 1.0,
channels_json: json!({}),
created_at_unix_secs: 1,
updated_at_unix_secs: 1,
}
}
#[test]
fn legacy_secret_reuse_requires_reentry_after_binding_change() {
let legacy = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "legacy-secret")
.expect("legacy secret should encrypt");
let old_record = gateway_record("https://api.stripe.com", "merchant-old", Some(legacy));
let changed_binding =
PaymentGatewaySecretBinding::new("stripe", "https://api.stripe.com", "merchant-new")
.expect("changed binding should be valid");
assert!(legacy_secret_reuse_requires_reentry(
Some(&old_record),
&changed_binding,
));
let v2_record = gateway_record(
"https://api.stripe.com",
"merchant-old",
Some("aether-payment-gateway-secret-v2:legacy".to_string()),
);
assert!(legacy_secret_reuse_requires_reentry(
Some(&v2_record),
&changed_binding,
));
}
#[test]
fn legacy_secret_reuse_is_allowed_only_for_same_binding_or_bound_v3() {
let legacy = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "legacy-secret")
.expect("legacy secret should encrypt");
let old_record =
gateway_record("https://API.STRIPE.COM:443/", "merchant-old", Some(legacy));
let same_binding = PaymentGatewaySecretBinding::new(
"stripe",
"https://api.stripe.com:443/",
" merchant-old ",
)
.expect("same binding should be valid");
assert!(!legacy_secret_reuse_requires_reentry(
Some(&old_record),
&same_binding,
));
let v3_record = gateway_record(
"https://api.stripe.com",
"merchant-old",
Some("aether-payment-gateway-secret-v3:bound".to_string()),
);
let changed_binding =
PaymentGatewaySecretBinding::new("stripe", "https://api.stripe.com", "merchant-new")
.expect("changed binding should be valid");
assert!(!legacy_secret_reuse_requires_reentry(
Some(&v3_record),
&changed_binding,
));
}
#[test]
fn stripe_secret_rotation_preserves_omitted_secret_fields() {
let existing = json!({
"secret_key": "old-secret-key",
"webhook_secret": "old-webhook"
})
.to_string();
let updates = json!({"webhook_secret": "new-webhook"})
.as_object()
.cloned()
.expect("updates should be an object");
let merged = Value::Object(
merge_gateway_secret_maps(Some(&existing), updates)
.expect("valid secret maps should merge"),
);
assert_eq!(merged["secret_key"], "old-secret-key");
assert_eq!(merged["webhook_secret"], "new-webhook");
}
#[test]
fn wxpay_secret_rotation_preserves_omitted_secret_fields() {
let existing = json!({
"private_key": "old-private",
"api_v3_key": "old-api-v3-key",
"public_key": "old-public"
})
.to_string();
let updates = json!({"api_v3_key": "new-api-v3-key"})
.as_object()
.cloned()
.expect("updates should be an object");
let merged = Value::Object(
merge_gateway_secret_maps(Some(&existing), updates)
.expect("valid secret maps should merge"),
);
assert_eq!(merged["private_key"], "old-private");
assert_eq!(merged["api_v3_key"], "new-api-v3-key");
assert_eq!(merged["public_key"], "old-public");
}
#[test]
fn generic_gateway_routes_never_fall_back_to_epay() {
assert_eq!(
resolve_admin_payment_gateway_provider(
"/api/admin/payments/gateways/stripe",
"update_payment_gateway",
)
.as_deref(),
Some("stripe")
);
assert!(resolve_admin_payment_gateway_provider(
"/api/admin/payments/gateways/unsupported",
"update_payment_gateway",
)
.is_none());
assert!(resolve_admin_payment_gateway_provider(
"/api/admin/payments/gateways/stripe/extra",
"get_payment_gateway",
)
.is_none());
assert_eq!(
resolve_admin_payment_gateway_provider(
"/api/admin/payments/epay",
"update_epay_gateway",
)
.as_deref(),
Some("epay")
);
}
}