mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -3,12 +3,16 @@ use super::{
|
||||
};
|
||||
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_keys_json,
|
||||
payment_gateway_secret_is_legacy_unbound, payment_gateway_secret_keys_json,
|
||||
PaymentGatewaySecretBinding,
|
||||
};
|
||||
use crate::{GatewayError, LocalMutationOutcome};
|
||||
use aether_data_contracts::repository::billing::PaymentGatewayConfigWriteInput;
|
||||
use aether_data_contracts::repository::billing::{
|
||||
PaymentGatewayConfigCasWriteInput, PaymentGatewayConfigWriteInput,
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
@@ -18,7 +22,9 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
const PAYMENT_GATEWAY_CONFIG_CAS_MAX_ATTEMPTS: usize = 8;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PaymentGatewayConfigRequest {
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
@@ -60,6 +66,14 @@ 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 {
|
||||
json!([
|
||||
{"channel": "alipay", "display_name": "支付宝", "fee_rate": 0.0},
|
||||
@@ -121,6 +135,18 @@ fn admin_payment_gateway_provider_from_path(path: &str) -> Option<String> {
|
||||
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(),
|
||||
@@ -263,29 +289,97 @@ fn normalize_config_object(config: Value) -> Result<Value, String> {
|
||||
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<'_>,
|
||||
provider: &str,
|
||||
binding: &PaymentGatewaySecretBinding,
|
||||
payload: &PaymentGatewayConfigRequest,
|
||||
) -> Result<Option<String>, Response<Body>> {
|
||||
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" {
|
||||
payload
|
||||
let supplied = payload
|
||||
.merchant_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.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() {
|
||||
Ok(None)
|
||||
decrypt_existing()?;
|
||||
Ok((None, existing_gateway_secret_keys(existing)))
|
||||
} else {
|
||||
Err(build_admin_payments_bad_request_response(
|
||||
"secrets must be an object",
|
||||
))
|
||||
};
|
||||
};
|
||||
let filtered = secrets
|
||||
let updates = secrets
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let value = value.as_str()?.trim();
|
||||
@@ -293,39 +387,60 @@ fn encrypted_gateway_secret(
|
||||
.then(|| (key.trim().to_string(), Value::String(value.to_string())))
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
if filtered.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(filtered).to_string())
|
||||
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);
|
||||
return Ok((None, existing_gateway_secret_keys(existing)));
|
||||
};
|
||||
state
|
||||
.encrypt_catalog_secret_with_fallbacks(&secret_plaintext)
|
||||
.ok_or_else(|| {
|
||||
build_admin_payments_backend_unavailable_response("encryption key is not configured")
|
||||
})
|
||||
.map(Some)
|
||||
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))
|
||||
}
|
||||
|
||||
async fn existing_gateway_secret_keys(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &str,
|
||||
) -> Result<Vec<Value>, GatewayError> {
|
||||
let Some(record) = state.app().find_payment_gateway_config(provider).await? else {
|
||||
return Ok(Vec::new());
|
||||
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);
|
||||
Ok(secret_keys
|
||||
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())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_payment_gateways_response(
|
||||
@@ -336,8 +451,14 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
match route_kind {
|
||||
Some("get_epay_gateway") | Some("get_payment_gateway") => {
|
||||
let provider = admin_payment_gateway_provider_from_path(request_context.path())
|
||||
.unwrap_or_else(|| "epay".to_string());
|
||||
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)
|
||||
@@ -345,8 +466,14 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
|
||||
Ok(Some(Json(payload).into_response()))
|
||||
}
|
||||
Some("update_epay_gateway") | Some("update_payment_gateway") => {
|
||||
let provider = admin_payment_gateway_provider_from_path(request_context.path())
|
||||
.unwrap_or_else(|| "epay".to_string());
|
||||
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 Some(body) = request_body else {
|
||||
return Ok(Some(build_admin_payments_bad_request_response(
|
||||
"缺少请求体",
|
||||
@@ -371,109 +498,167 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_response(
|
||||
)));
|
||||
}
|
||||
|
||||
let merchant_key_encrypted = match encrypted_gateway_secret(state, &provider, &payload)
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(response) => return Ok(Some(response)),
|
||||
};
|
||||
let endpoint_url = if provider == "epay" {
|
||||
match normalize_text(payload.endpoint_url, "endpoint_url", 512) {
|
||||
Ok(value) => value,
|
||||
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), 512) {
|
||||
Ok(value) => value.unwrap_or_default(),
|
||||
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)))
|
||||
}
|
||||
}
|
||||
};
|
||||
let callback_base_url = match normalize_optional_text(payload.callback_base_url, 512) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
};
|
||||
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, "merchant_id", 128) {
|
||||
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), 128) {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
};
|
||||
let pay_currency = match normalize_text(payload.pay_currency, "pay_currency", 16) {
|
||||
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()) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
};
|
||||
let config = match normalize_config_object(payload.config) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
||||
};
|
||||
let submitted_secret_keys = payload
|
||||
.secrets
|
||||
.as_object()
|
||||
.map(|secrets| {
|
||||
secrets
|
||||
.iter()
|
||||
.filter(|(_, value)| {
|
||||
value.as_str().is_some_and(|value| !value.trim().is_empty())
|
||||
})
|
||||
.map(|(key, _)| Value::String(key.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let secret_keys = if provider == "epay" || !submitted_secret_keys.is_empty() {
|
||||
submitted_secret_keys
|
||||
} else {
|
||||
existing_gateway_secret_keys(state, &provider).await?
|
||||
};
|
||||
let channels = match normalize_gateway_channels(&provider, payload.channels) {
|
||||
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 channels_json = payment_gateway_channels_config_json(
|
||||
channels,
|
||||
config,
|
||||
Value::Array(secret_keys),
|
||||
refund_enabled,
|
||||
allow_user_refund,
|
||||
);
|
||||
let input = PaymentGatewayConfigWriteInput {
|
||||
provider: provider.clone(),
|
||||
enabled: payload.enabled,
|
||||
endpoint_url,
|
||||
callback_base_url,
|
||||
merchant_id,
|
||||
preserve_existing_secret: merchant_key_encrypted.is_none(),
|
||||
merchant_key_encrypted,
|
||||
pay_currency,
|
||||
usd_exchange_rate: payload.usd_exchange_rate,
|
||||
min_recharge_usd: payload.min_recharge_usd,
|
||||
channels_json,
|
||||
};
|
||||
match state.app().upsert_payment_gateway_config(&input).await? {
|
||||
LocalMutationOutcome::Applied(record) => {
|
||||
Ok(Some(Json(gateway_config_payload(record)).into_response()))
|
||||
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",
|
||||
)));
|
||||
}
|
||||
}
|
||||
_ => Ok(Some(build_admin_payments_backend_unavailable_response(
|
||||
"payment gateway config backend unavailable",
|
||||
))),
|
||||
}
|
||||
Ok(Some(build_payment_gateway_conflict_response(
|
||||
"payment gateway config changed too frequently; retry the request",
|
||||
)))
|
||||
}
|
||||
Some("test_epay_gateway") | Some("test_payment_gateway") => {
|
||||
let provider = admin_payment_gateway_provider_from_path(request_context.path())
|
||||
.unwrap_or_else(|| "epay".to_string());
|
||||
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?;
|
||||
let ok = status
|
||||
.as_ref()
|
||||
@@ -493,3 +678,164 @@ pub(super) async fn maybe_build_local_admin_payment_gateways_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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ mod redeem_codes;
|
||||
mod routes;
|
||||
mod shared;
|
||||
|
||||
pub(in crate::handlers::admin) use self::shared::admin_payment_gateway_response_projection;
|
||||
use self::shared::{
|
||||
admin_payment_operator_id, admin_payment_order_id_from_detail_path,
|
||||
admin_payment_order_id_from_suffix_path, build_admin_payment_callback_payload_from_record,
|
||||
@@ -19,7 +20,8 @@ use self::shared::{
|
||||
build_admin_payments_bad_request_response, build_admin_payments_data_unavailable_response,
|
||||
normalize_admin_payment_currency, normalize_admin_payment_optional_string,
|
||||
normalize_admin_payment_positive_number, parse_admin_payments_limit,
|
||||
parse_admin_payments_offset, AdminPaymentOrderCreditRequest,
|
||||
parse_admin_payments_offset, prepare_admin_payment_gateway_response_for_storage,
|
||||
AdminPaymentOrderCreditRequest,
|
||||
};
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_payments_response(
|
||||
|
||||
@@ -5,7 +5,8 @@ use super::{
|
||||
build_admin_payments_backend_unavailable_response, build_admin_payments_bad_request_response,
|
||||
normalize_admin_payment_currency, normalize_admin_payment_optional_string,
|
||||
normalize_admin_payment_positive_number, parse_admin_payments_limit,
|
||||
parse_admin_payments_offset, AdminPaymentOrderCreditRequest,
|
||||
parse_admin_payments_offset, prepare_admin_payment_gateway_response_for_storage,
|
||||
AdminPaymentOrderCreditRequest,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_value};
|
||||
@@ -124,7 +125,9 @@ async fn close_direct_gateway_order_before_terminal_mark(
|
||||
)))
|
||||
}
|
||||
};
|
||||
if order.status != "pending" || !matches!(order.payment_method.as_str(), "alipay" | "wxpay") {
|
||||
if order.status != "pending"
|
||||
|| !matches!(order.payment_method.as_str(), "alipay" | "wxpay" | "stripe")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
crate::handlers::shared::close_direct_gateway_order(state.app(), &order)
|
||||
@@ -231,6 +234,8 @@ async fn build_admin_payment_credit_order_response(
|
||||
"gateway_response 必须为对象",
|
||||
));
|
||||
}
|
||||
let gateway_response =
|
||||
prepare_admin_payment_gateway_response_for_storage(payload.gateway_response);
|
||||
let operator_id = admin_payment_operator_id(request_context);
|
||||
match state
|
||||
.admin_credit_payment_order(
|
||||
@@ -239,7 +244,7 @@ async fn build_admin_payment_credit_order_response(
|
||||
pay_amount,
|
||||
pay_currency.as_deref(),
|
||||
exchange_rate,
|
||||
payload.gateway_response,
|
||||
gateway_response,
|
||||
operator_id.as_deref(),
|
||||
)
|
||||
.await?
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::handlers::admin::shared::{
|
||||
attach_admin_audit_response, query_param_value, unix_secs_to_rfc3339,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use aether_data::repository::wallet::stored_timestamp_unix_secs;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
@@ -122,7 +123,7 @@ fn build_batch_payload(
|
||||
"description": batch.description,
|
||||
"created_by": batch.created_by,
|
||||
"expires_at": batch.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"created_at": unix_secs_to_rfc3339(batch.created_at_unix_ms),
|
||||
"created_at": unix_secs_to_rfc3339(stored_timestamp_unix_secs(batch.created_at_unix_ms)),
|
||||
"updated_at": unix_secs_to_rfc3339(batch.updated_at_unix_secs),
|
||||
})
|
||||
}
|
||||
@@ -146,7 +147,7 @@ fn build_code_payload(
|
||||
"redeemed_at": code.redeemed_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"disabled_by": code.disabled_by,
|
||||
"expires_at": code.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"created_at": unix_secs_to_rfc3339(code.created_at_unix_ms),
|
||||
"created_at": unix_secs_to_rfc3339(stored_timestamp_unix_secs(code.created_at_unix_ms)),
|
||||
"updated_at": unix_secs_to_rfc3339(code.updated_at_unix_secs),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
use crate::handlers::admin::request::AdminRequestContext;
|
||||
use crate::handlers::admin::shared::{query_param_value, unix_secs_to_rfc3339};
|
||||
use crate::handlers::shared::normalize_payment_currency;
|
||||
use crate::GatewayAdminPaymentCallbackView;
|
||||
use aether_data::repository::wallet::stored_timestamp_unix_secs;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const ADMIN_PAYMENTS_DATA_UNAVAILABLE_DETAIL: &str = "Admin payments data unavailable";
|
||||
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
#[derive(Default, serde::Deserialize)]
|
||||
pub(super) struct AdminPaymentOrderCreditRequest {
|
||||
#[serde(default)]
|
||||
pub(super) gateway_order_id: Option<String>,
|
||||
@@ -25,6 +27,22 @@ pub(super) struct AdminPaymentOrderCreditRequest {
|
||||
pub(super) gateway_response: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AdminPaymentOrderCreditRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminPaymentOrderCreditRequest")
|
||||
.field("gateway_order_id", &self.gateway_order_id)
|
||||
.field("pay_amount", &self.pay_amount)
|
||||
.field("pay_currency", &self.pay_currency)
|
||||
.field("exchange_rate", &self.exchange_rate)
|
||||
.field(
|
||||
"gateway_response",
|
||||
&self.gateway_response.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_payments_data_unavailable_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -153,11 +171,9 @@ pub(super) fn normalize_admin_payment_currency(
|
||||
let Some(value) = normalize_admin_payment_optional_string(value, "pay_currency", 3)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let normalized = value.to_ascii_uppercase();
|
||||
if normalized.len() != 3 {
|
||||
return Err("pay_currency 必须是 3 位货币代码".to_string());
|
||||
}
|
||||
Ok(Some(normalized))
|
||||
normalize_payment_currency(&value, "pay_currency")
|
||||
.map(Some)
|
||||
.map_err(|_| "pay_currency 必须是 3 位 ASCII 货币代码".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn normalize_admin_payment_positive_number(
|
||||
@@ -187,13 +203,184 @@ pub(super) fn admin_payment_effective_status(
|
||||
expires_at_unix_secs: Option<u64>,
|
||||
) -> String {
|
||||
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
|
||||
if status == "pending" && expires_at_unix_secs.is_some_and(|value| value < now_unix_secs) {
|
||||
if status == "pending" && expires_at_unix_secs.is_some_and(|value| value <= now_unix_secs) {
|
||||
"expired".to_string()
|
||||
} else {
|
||||
status.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_payment_bounded_string(value: &Value, max_chars: usize) -> Option<Value> {
|
||||
let value = value.as_str()?.trim();
|
||||
(!value.is_empty() && value.chars().count() <= max_chars)
|
||||
.then(|| Value::String(value.to_string()))
|
||||
}
|
||||
|
||||
fn admin_payment_identifier(value: &Value, max_chars: usize) -> Option<Value> {
|
||||
let value = value.as_str()?.trim();
|
||||
(!value.is_empty()
|
||||
&& value.chars().count() <= max_chars
|
||||
&& value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')))
|
||||
.then(|| Value::String(value.to_string()))
|
||||
}
|
||||
|
||||
fn admin_payment_gateway_response_field(key: &str, value: &Value) -> Option<Value> {
|
||||
match key {
|
||||
"gateway" | "submit_method" | "payment_channel" => admin_payment_identifier(value, 64),
|
||||
"pay_currency" => admin_payment_identifier(value, 16),
|
||||
"display_name" | "provider_label" => admin_payment_bounded_string(value, 128),
|
||||
"gateway_order_id" | "intent_id" => admin_payment_bounded_string(value, 256),
|
||||
"expires_at" => admin_payment_bounded_string(value, 64),
|
||||
"pay_amount" | "base_pay_amount" | "fee_rate" | "fee_amount" => {
|
||||
value.is_number().then(|| value.clone())
|
||||
}
|
||||
"manual_credit" => value.as_bool().map(Value::Bool),
|
||||
"payment_method_types" => {
|
||||
let values = value.as_array()?;
|
||||
if values.len() > 16 {
|
||||
return None;
|
||||
}
|
||||
values
|
||||
.iter()
|
||||
.map(|value| admin_payment_identifier(value, 64))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.map(Value::Array)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::handlers::admin) fn admin_payment_gateway_response_projection(
|
||||
value: Option<&Value>,
|
||||
) -> Value {
|
||||
let Some(object) = value.and_then(Value::as_object) else {
|
||||
return Value::Null;
|
||||
};
|
||||
Value::Object(
|
||||
object
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
admin_payment_gateway_response_field(key, value).map(|value| (key.clone(), value))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_admin_payment_gateway_response_for_storage(
|
||||
value: Option<Value>,
|
||||
) -> Option<Value> {
|
||||
value.map(|value| admin_payment_gateway_response_projection(Some(&value)))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AdminPaymentJsonShape {
|
||||
objects: u64,
|
||||
arrays: u64,
|
||||
strings: u64,
|
||||
numbers: u64,
|
||||
booleans: u64,
|
||||
nulls: u64,
|
||||
object_fields: u64,
|
||||
array_items: u64,
|
||||
max_depth: u64,
|
||||
}
|
||||
|
||||
impl AdminPaymentJsonShape {
|
||||
fn observe(&mut self, value: &Value, depth: u64) {
|
||||
self.max_depth = self.max_depth.max(depth);
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
self.objects = self.objects.saturating_add(1);
|
||||
self.object_fields = self
|
||||
.object_fields
|
||||
.saturating_add(u64::try_from(object.len()).unwrap_or(u64::MAX));
|
||||
for value in object.values() {
|
||||
self.observe(value, depth.saturating_add(1));
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
self.arrays = self.arrays.saturating_add(1);
|
||||
self.array_items = self
|
||||
.array_items
|
||||
.saturating_add(u64::try_from(values.len()).unwrap_or(u64::MAX));
|
||||
for value in values {
|
||||
self.observe(value, depth.saturating_add(1));
|
||||
}
|
||||
}
|
||||
Value::String(_) => self.strings = self.strings.saturating_add(1),
|
||||
Value::Number(_) => self.numbers = self.numbers.saturating_add(1),
|
||||
Value::Bool(_) => self.booleans = self.booleans.saturating_add(1),
|
||||
Value::Null => self.nulls = self.nulls.saturating_add(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_payment_json_kind(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_payment_payload_summary(value: Option<&Value>) -> Value {
|
||||
let Some(value) = value else {
|
||||
return Value::Null;
|
||||
};
|
||||
let mut shape = AdminPaymentJsonShape::default();
|
||||
shape.observe(value, 1);
|
||||
json!({
|
||||
"kind": admin_payment_json_kind(value),
|
||||
"serialized_bytes": serde_json::to_vec(value).map_or(0, |encoded| encoded.len()),
|
||||
"objects": shape.objects,
|
||||
"arrays": shape.arrays,
|
||||
"strings": shape.strings,
|
||||
"numbers": shape.numbers,
|
||||
"booleans": shape.booleans,
|
||||
"nulls": shape.nulls,
|
||||
"object_fields": shape.object_fields,
|
||||
"array_items": shape.array_items,
|
||||
"max_depth": shape.max_depth,
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_payment_callback_error_projection(value: Option<&str>) -> Option<String> {
|
||||
const SAFE_ERRORS: &[&str] = &[
|
||||
"callback amount mismatch",
|
||||
"callback key reused with different payment payload",
|
||||
"invalid callback signature",
|
||||
"invalid payment callback numeric or identity fields",
|
||||
"payment channel mismatch",
|
||||
"payment currency mismatch",
|
||||
"payment gateway order belongs to another payment order",
|
||||
"payment gateway order identifier mismatch",
|
||||
"payment gateway order mismatch",
|
||||
"payment method mismatch",
|
||||
"payment order expired",
|
||||
"payment order not found",
|
||||
"payment order number mismatch",
|
||||
"payment order user missing",
|
||||
"payment provider mismatch",
|
||||
"plan purchase limit reached",
|
||||
"wallet is not active",
|
||||
"wallet not found",
|
||||
];
|
||||
|
||||
let value = value?.trim();
|
||||
if SAFE_ERRORS.contains(&value) {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
if value.starts_with("payment order is not creditable:") {
|
||||
return Some("payment order is not creditable".to_string());
|
||||
}
|
||||
Some("payment callback processing failed".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_payment_order_payload(
|
||||
record: &crate::AdminWalletPaymentOrderRecord,
|
||||
) -> serde_json::Value {
|
||||
@@ -210,9 +397,10 @@ pub(super) fn build_admin_payment_order_payload(
|
||||
"refundable_amount_usd": record.refundable_amount_usd,
|
||||
"payment_method": record.payment_method,
|
||||
"gateway_order_id": record.gateway_order_id,
|
||||
"gateway_response": record.gateway_response,
|
||||
"gateway_response": admin_payment_gateway_response_projection(record.gateway_response.as_ref()),
|
||||
"has_gateway_response": record.gateway_response.is_some(),
|
||||
"status": admin_payment_effective_status(&record.status, record.expires_at_unix_secs),
|
||||
"created_at": unix_secs_to_rfc3339(record.created_at_unix_ms),
|
||||
"created_at": unix_secs_to_rfc3339(stored_timestamp_unix_secs(record.created_at_unix_ms)),
|
||||
"paid_at": record.paid_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"credited_at": record.credited_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"expires_at": record.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
@@ -232,9 +420,196 @@ pub(super) fn build_admin_payment_callback_payload_from_record(
|
||||
"payload_hash": record.payload_hash,
|
||||
"signature_valid": record.signature_valid,
|
||||
"status": record.status,
|
||||
"payload": record.payload,
|
||||
"error_message": record.error_message,
|
||||
"created_at": unix_secs_to_rfc3339(record.created_at_unix_ms),
|
||||
"payload": Value::Null,
|
||||
"has_payload": record.payload.is_some(),
|
||||
"payload_summary": admin_payment_payload_summary(record.payload.as_ref()),
|
||||
"error_message": admin_payment_callback_error_projection(record.error_message.as_deref()),
|
||||
"has_error_message": record.error_message.is_some(),
|
||||
"created_at": unix_secs_to_rfc3339(stored_timestamp_unix_secs(record.created_at_unix_ms)),
|
||||
"processed_at": record.processed_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_admin_payment_callback_payload_from_record, build_admin_payment_order_payload,
|
||||
prepare_admin_payment_gateway_response_for_storage,
|
||||
};
|
||||
use crate::{AdminWalletPaymentOrderRecord, GatewayAdminPaymentCallbackView};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn admin_payment_order_projection_excludes_replayable_gateway_fields() {
|
||||
let record = AdminWalletPaymentOrderRecord {
|
||||
id: "order-1".to_string(),
|
||||
order_no: "merchant-order-1".to_string(),
|
||||
wallet_id: "wallet-1".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
amount_usd: 10.0,
|
||||
pay_amount: Some(72.0),
|
||||
pay_currency: Some("CNY".to_string()),
|
||||
exchange_rate: Some(7.2),
|
||||
refunded_amount_usd: 0.0,
|
||||
refundable_amount_usd: 0.0,
|
||||
payment_method: "stripe".to_string(),
|
||||
gateway_order_id: Some("pi_1".to_string()),
|
||||
status: "pending".to_string(),
|
||||
gateway_response: Some(json!({
|
||||
"gateway": "stripe",
|
||||
"intent_id": "pi_1",
|
||||
"client_secret": "pi_1_secret_replayable",
|
||||
"payment_url": "https://pay.example/checkout?token=secret",
|
||||
"payment_params": {"sign": "signed-secret"},
|
||||
"customer_email": "[email protected]"
|
||||
})),
|
||||
created_at_unix_ms: 1,
|
||||
paid_at_unix_secs: None,
|
||||
credited_at_unix_secs: None,
|
||||
expires_at_unix_secs: None,
|
||||
};
|
||||
|
||||
let payload = build_admin_payment_order_payload(&record);
|
||||
assert_eq!(payload["has_gateway_response"], true);
|
||||
assert_eq!(
|
||||
payload.pointer("/gateway_response/gateway"),
|
||||
Some(&json!("stripe"))
|
||||
);
|
||||
assert_eq!(
|
||||
payload.pointer("/gateway_response/intent_id"),
|
||||
Some(&json!("pi_1"))
|
||||
);
|
||||
for key in [
|
||||
"client_secret",
|
||||
"payment_url",
|
||||
"payment_params",
|
||||
"customer_email",
|
||||
] {
|
||||
assert!(payload
|
||||
.pointer(&format!("/gateway_response/{key}"))
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_payment_order_projection_rejects_nested_or_mistyped_safe_fields() {
|
||||
let mut record = AdminWalletPaymentOrderRecord {
|
||||
id: "order-1".to_string(),
|
||||
order_no: "merchant-order-1".to_string(),
|
||||
wallet_id: "wallet-1".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
amount_usd: 10.0,
|
||||
pay_amount: Some(72.0),
|
||||
pay_currency: Some("CNY".to_string()),
|
||||
exchange_rate: Some(7.2),
|
||||
refunded_amount_usd: 0.0,
|
||||
refundable_amount_usd: 0.0,
|
||||
payment_method: "stripe".to_string(),
|
||||
gateway_order_id: Some("pi_1".to_string()),
|
||||
status: "pending".to_string(),
|
||||
gateway_response: None,
|
||||
created_at_unix_ms: 1,
|
||||
paid_at_unix_secs: None,
|
||||
credited_at_unix_secs: None,
|
||||
expires_at_unix_secs: None,
|
||||
};
|
||||
record.gateway_response = Some(json!({
|
||||
"gateway": {"client_secret": "secret-in-nested-object"},
|
||||
"intent_id": ["pi_1", "secret-in-array"],
|
||||
"payment_method_types": ["card", {"secret": "nested"}],
|
||||
"manual_credit": "secret-in-string",
|
||||
}));
|
||||
|
||||
let encoded = build_admin_payment_order_payload(&record).to_string();
|
||||
assert!(!encoded.contains("secret-in-nested-object"));
|
||||
assert!(!encoded.contains("secret-in-array"));
|
||||
assert!(!encoded.contains("nested"));
|
||||
assert!(!encoded.contains("secret-in-string"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_payment_gateway_response_is_projected_before_storage() {
|
||||
let projected = prepare_admin_payment_gateway_response_for_storage(Some(json!({
|
||||
"gateway": "stripe",
|
||||
"intent_id": "pi_1",
|
||||
"client_secret": "pi_1_secret_replayable",
|
||||
"customer": {"email": "[email protected]"},
|
||||
"payment_params": {"authorization": "Bearer secret"},
|
||||
})))
|
||||
.expect("provided gateway response should remain present");
|
||||
|
||||
assert_eq!(projected, json!({"gateway": "stripe", "intent_id": "pi_1"}));
|
||||
let encoded = projected.to_string();
|
||||
for forbidden in [
|
||||
"client_secret",
|
||||
"replayable",
|
||||
"customer",
|
||||
"[email protected]",
|
||||
"authorization",
|
||||
"Bearer secret",
|
||||
] {
|
||||
assert!(!encoded.contains(forbidden), "persisted {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_payment_callback_projection_does_not_return_raw_payload() {
|
||||
let record = GatewayAdminPaymentCallbackView {
|
||||
id: "callback-1".to_string(),
|
||||
payment_order_id: Some("order-1".to_string()),
|
||||
payment_method: "stripe".to_string(),
|
||||
callback_key: "stripe:event-1".to_string(),
|
||||
order_no: Some("merchant-order-1".to_string()),
|
||||
gateway_order_id: Some("pi_1".to_string()),
|
||||
payload_hash: Some("hash-1".to_string()),
|
||||
signature_valid: true,
|
||||
status: "processed".to_string(),
|
||||
payload: Some(json!({
|
||||
"data": {"object": {"client_secret": "secret", "customer_email": "[email protected]"}}
|
||||
})),
|
||||
error_message: None,
|
||||
created_at_unix_ms: 1,
|
||||
processed_at_unix_secs: Some(1),
|
||||
};
|
||||
|
||||
let payload = build_admin_payment_callback_payload_from_record(&record);
|
||||
assert_eq!(payload["has_payload"], true);
|
||||
assert!(payload["payload"].is_null());
|
||||
assert_eq!(payload["payload_summary"]["kind"], "object");
|
||||
assert_eq!(payload["payload_summary"]["objects"], 3);
|
||||
assert_eq!(payload["payload_summary"]["strings"], 2);
|
||||
assert_eq!(payload["payload_summary"]["max_depth"], 4);
|
||||
let encoded = payload.to_string();
|
||||
assert!(!encoded.contains("customer_email"));
|
||||
assert!(!encoded.contains("[email protected]"));
|
||||
assert!(!encoded.contains("client_secret"));
|
||||
assert!(!encoded.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_payment_callback_projection_does_not_return_unknown_historical_errors() {
|
||||
let record = GatewayAdminPaymentCallbackView {
|
||||
id: "callback-1".to_string(),
|
||||
payment_order_id: None,
|
||||
payment_method: "stripe".to_string(),
|
||||
callback_key: "stripe:event-1".to_string(),
|
||||
order_no: None,
|
||||
gateway_order_id: None,
|
||||
payload_hash: None,
|
||||
signature_valid: false,
|
||||
status: "failed".to_string(),
|
||||
payload: None,
|
||||
error_message: Some("upstream rejected sk_live_secret_value".to_string()),
|
||||
created_at_unix_ms: 1,
|
||||
processed_at_unix_secs: Some(1),
|
||||
};
|
||||
|
||||
let payload = build_admin_payment_callback_payload_from_record(&record);
|
||||
assert_eq!(payload["has_error_message"], true);
|
||||
assert_eq!(
|
||||
payload["error_message"],
|
||||
"payment callback processing failed"
|
||||
);
|
||||
assert!(!payload.to_string().contains("sk_live_secret_value"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user