mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +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:
@@ -234,6 +234,23 @@ impl AppState {
|
||||
record: aether_data::repository::auth::CreateUserApiKeyRecord,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
#[cfg(test)]
|
||||
{
|
||||
// Unit-test AppState instances keep users and API keys in separate in-memory
|
||||
// repositories. Bridge them with the authoritative user record only; an unknown,
|
||||
// inactive, or deleted user must never be synthesized from the key request.
|
||||
let Some(user) = self.find_user_auth_by_id(&record.user_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !user.is_active || user.is_deleted {
|
||||
return Ok(None);
|
||||
}
|
||||
self.data
|
||||
.synchronize_user_api_key_owner_for_tests(&user)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
}
|
||||
|
||||
let api_key = self
|
||||
.data
|
||||
.create_user_api_key(record)
|
||||
@@ -277,6 +294,32 @@ impl AppState {
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn compare_and_swap_api_key_ciphertext(
|
||||
&self,
|
||||
mutation: &aether_data::repository::auth::CompareAndSwapAuthApiKeyCiphertext,
|
||||
) -> Result<bool, GatewayError> {
|
||||
self.data
|
||||
.compare_and_swap_api_key_ciphertext(mutation)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn update_user_api_key_basic_if_unlocked(
|
||||
&self,
|
||||
record: aether_data::repository::auth::UpdateUserApiKeyBasicRecord,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
let api_key = self
|
||||
.data
|
||||
.update_user_api_key_basic_if_unlocked(record)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if api_key.is_some() {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_standalone_api_key_basic(
|
||||
&self,
|
||||
record: aether_data::repository::auth::UpdateStandaloneApiKeyBasicRecord,
|
||||
@@ -293,6 +336,22 @@ impl AppState {
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_api_key_if_matches(
|
||||
&self,
|
||||
expected: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
|
||||
restored: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
|
||||
) -> Result<bool, GatewayError> {
|
||||
let restored = self
|
||||
.data
|
||||
.restore_api_key_if_matches(expected, restored)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if restored {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_active(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -311,6 +370,24 @@ impl AppState {
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_active_if_unlocked(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
is_active: bool,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
let api_key = self
|
||||
.data
|
||||
.set_user_api_key_active_if_unlocked(user_id, api_key_id, is_active)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if api_key.is_some() {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_standalone_api_key_active(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
@@ -363,6 +440,24 @@ impl AppState {
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_allowed_providers_if_unlocked(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
allowed_providers: Option<Vec<String>>,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
let api_key = self
|
||||
.data
|
||||
.set_user_api_key_allowed_providers_if_unlocked(user_id, api_key_id, allowed_providers)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if api_key.is_some() {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_force_capabilities(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -381,6 +476,28 @@ impl AppState {
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_force_capabilities_if_unlocked(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
force_capabilities: Option<serde_json::Value>,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
let api_key = self
|
||||
.data
|
||||
.set_user_api_key_force_capabilities_if_unlocked(
|
||||
user_id,
|
||||
api_key_id,
|
||||
force_capabilities,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if api_key.is_some() {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_feature_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -399,6 +516,24 @@ impl AppState {
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_user_api_key_feature_settings_if_unlocked(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
|
||||
{
|
||||
let api_key = self
|
||||
.data
|
||||
.set_user_api_key_feature_settings_if_unlocked(user_id, api_key_id, feature_settings)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if api_key.is_some() {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_api_key_usage_totals(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
@@ -451,6 +586,22 @@ impl AppState {
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_user_api_key_if_unlocked(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
) -> Result<bool, GatewayError> {
|
||||
let deleted = self
|
||||
.data
|
||||
.delete_user_api_key_if_unlocked(user_id, api_key_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if deleted {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_standalone_api_key(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
@@ -466,3 +617,190 @@ impl AppState {
|
||||
Ok(deleted)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_data::repository::auth::{
|
||||
AuthApiKeyLookupKey, AuthApiKeyReadRepository, CreateUserApiKeyRecord,
|
||||
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot,
|
||||
};
|
||||
use aether_data::repository::users::StoredUserAuthRecord;
|
||||
|
||||
use crate::data::GatewayDataState;
|
||||
use crate::AppState;
|
||||
|
||||
fn authoritative_user(
|
||||
user_id: &str,
|
||||
is_active: bool,
|
||||
is_deleted: bool,
|
||||
) -> StoredUserAuthRecord {
|
||||
StoredUserAuthRecord::new(
|
||||
user_id.to_string(),
|
||||
Some(format!("{user_id}@example.com")),
|
||||
true,
|
||||
format!("owner-{user_id}"),
|
||||
Some("server-managed-password-hash".to_string()),
|
||||
"admin".to_string(),
|
||||
"oauth".to_string(),
|
||||
Some(serde_json::json!(["openai"])),
|
||||
Some(serde_json::json!(["openai:chat"])),
|
||||
Some(serde_json::json!(["gpt-5"])),
|
||||
is_active,
|
||||
is_deleted,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("authoritative user should build")
|
||||
.with_security_version(41)
|
||||
.expect("security version should be valid")
|
||||
}
|
||||
|
||||
fn create_record(user_id: &str, api_key_id: &str) -> CreateUserApiKeyRecord {
|
||||
CreateUserApiKeyRecord {
|
||||
user_id: user_id.to_string(),
|
||||
api_key_id: api_key_id.to_string(),
|
||||
key_hash: format!("hash-{api_key_id}"),
|
||||
key_encrypted: Some(format!("encrypted-{api_key_id}")),
|
||||
name: Some("first key".to_string()),
|
||||
allowed_providers: Some(vec!["anthropic".to_string()]),
|
||||
allowed_api_formats: None,
|
||||
allowed_models: None,
|
||||
ip_rules: None,
|
||||
rate_limit: 0,
|
||||
concurrent_limit: None,
|
||||
force_capabilities: None,
|
||||
feature_settings: None,
|
||||
is_active: true,
|
||||
expires_at_unix_secs: None,
|
||||
auto_delete_on_expiry: false,
|
||||
total_requests: 0,
|
||||
total_tokens: 0,
|
||||
total_cost_usd: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn state_with_users<I>(
|
||||
repository: Arc<InMemoryAuthApiKeySnapshotRepository>,
|
||||
users: I,
|
||||
) -> AppState
|
||||
where
|
||||
I: IntoIterator<Item = StoredUserAuthRecord>,
|
||||
{
|
||||
AppState::new()
|
||||
.expect("gateway state should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_auth_api_key_repository_for_tests(
|
||||
repository,
|
||||
))
|
||||
.with_auth_users_for_tests(users)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_gateway_first_user_key_requires_authoritative_active_owner() {
|
||||
let unknown_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::default());
|
||||
let unknown_state = state_with_users(
|
||||
Arc::clone(&unknown_repository),
|
||||
Vec::<StoredUserAuthRecord>::new(),
|
||||
);
|
||||
assert!(unknown_state
|
||||
.create_user_api_key(create_record("missing-user", "missing-key"))
|
||||
.await
|
||||
.expect("unknown owner creation should resolve")
|
||||
.is_none());
|
||||
assert!(unknown_repository
|
||||
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId("missing-key"))
|
||||
.await
|
||||
.expect("unknown key lookup should resolve")
|
||||
.is_none());
|
||||
|
||||
for (user_id, is_active, is_deleted) in [
|
||||
("inactive-user", false, false),
|
||||
("deleted-user", true, true),
|
||||
] {
|
||||
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::default());
|
||||
let state = state_with_users(
|
||||
Arc::clone(&repository),
|
||||
[authoritative_user(user_id, is_active, is_deleted)],
|
||||
);
|
||||
let api_key_id = format!("{user_id}-key");
|
||||
assert!(state
|
||||
.create_user_api_key(create_record(user_id, &api_key_id))
|
||||
.await
|
||||
.expect("ineligible owner creation should resolve")
|
||||
.is_none());
|
||||
assert!(repository
|
||||
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId(&api_key_id))
|
||||
.await
|
||||
.expect("ineligible key lookup should resolve")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_gateway_first_user_key_syncs_owner_without_mutating_authority() {
|
||||
let stale_owner = StoredAuthApiKeySnapshot::new(
|
||||
"active-user".to_string(),
|
||||
"request-derived-owner".to_string(),
|
||||
None,
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"ignored-owner-fixture".to_string(),
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("stale owner fixture should build");
|
||||
let repository = Arc::new(
|
||||
InMemoryAuthApiKeySnapshotRepository::default().with_owner_snapshots([stale_owner]),
|
||||
);
|
||||
let authoritative = authoritative_user("active-user", true, false);
|
||||
let state = state_with_users(Arc::clone(&repository), [authoritative.clone()]);
|
||||
|
||||
state
|
||||
.create_user_api_key(create_record("active-user", "active-key"))
|
||||
.await
|
||||
.expect("active owner creation should resolve")
|
||||
.expect("active authoritative owner should allow its first key");
|
||||
|
||||
let snapshot = repository
|
||||
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId("active-key"))
|
||||
.await
|
||||
.expect("created key lookup should resolve")
|
||||
.expect("created key should exist");
|
||||
assert_eq!(snapshot.user_role, "admin");
|
||||
assert!(snapshot.user_is_active);
|
||||
assert!(!snapshot.user_is_deleted);
|
||||
assert_eq!(
|
||||
snapshot.user_allowed_providers,
|
||||
Some(vec!["openai".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.api_key_allowed_providers,
|
||||
Some(vec!["anthropic".to_string()])
|
||||
);
|
||||
|
||||
let unchanged = state
|
||||
.find_user_auth_by_id("active-user")
|
||||
.await
|
||||
.expect("authoritative owner lookup should resolve")
|
||||
.expect("authoritative owner should remain");
|
||||
assert_eq!(unchanged, authoritative);
|
||||
assert_eq!(unchanged.role, authoritative.role);
|
||||
assert_eq!(unchanged.is_active, authoritative.is_active);
|
||||
assert_eq!(unchanged.is_deleted, authoritative.is_deleted);
|
||||
assert_eq!(unchanged.security_version, 41);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,13 +122,44 @@ impl AppState {
|
||||
{
|
||||
let session = session.into();
|
||||
#[cfg(test)]
|
||||
if let Some(store) = self.auth_session_store.as_ref() {
|
||||
if let (Some(user_store), Some(session_store)) = (
|
||||
self.auth_user_store.as_ref(),
|
||||
self.auth_session_store.as_ref(),
|
||||
) {
|
||||
let existing = {
|
||||
user_store
|
||||
.lock()
|
||||
.expect("auth user store should lock")
|
||||
.get(&session.user_id)
|
||||
.cloned()
|
||||
};
|
||||
let existing = match existing {
|
||||
Some(user) => Some(user),
|
||||
None => self
|
||||
.data
|
||||
.find_user_auth_by_id(&session.user_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?,
|
||||
};
|
||||
let Some(existing) = existing else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut users = user_store.lock().expect("auth user store should lock");
|
||||
let user = users.entry(session.user_id.clone()).or_insert(existing);
|
||||
if !user.is_active
|
||||
|| user.is_deleted
|
||||
|| user.security_version != session.security_version
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let now = session
|
||||
.created_at
|
||||
.or(session.updated_at)
|
||||
.or(session.last_seen_at)
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
let mut guard = store.lock().expect("auth session store should lock");
|
||||
let mut guard = session_store
|
||||
.lock()
|
||||
.expect("auth session store should lock");
|
||||
for existing in guard.values_mut() {
|
||||
if existing.user_id == session.user_id
|
||||
&& existing.client_device_id == session.client_device_id
|
||||
@@ -155,12 +186,96 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn create_user_session_if_password_matches<T>(
|
||||
&self,
|
||||
session: T,
|
||||
expected_password_hash: &str,
|
||||
) -> Result<Option<GatewayUserSessionView>, GatewayError>
|
||||
where
|
||||
T: Into<GatewayUserSessionView>,
|
||||
{
|
||||
let session = session.into();
|
||||
#[cfg(test)]
|
||||
if self.auth_session_store.is_some() && self.auth_user_store.is_some() {
|
||||
let existing = {
|
||||
self.auth_user_store
|
||||
.as_ref()
|
||||
.expect("checked auth user store")
|
||||
.lock()
|
||||
.expect("auth user store should lock")
|
||||
.get(&session.user_id)
|
||||
.cloned()
|
||||
};
|
||||
let existing = match existing {
|
||||
Some(user) => Some(user),
|
||||
None => self
|
||||
.data
|
||||
.find_user_auth_by_id(&session.user_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?,
|
||||
};
|
||||
let Some(existing) = existing else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut users = self
|
||||
.auth_user_store
|
||||
.as_ref()
|
||||
.expect("checked auth user store")
|
||||
.lock()
|
||||
.expect("auth user store should lock");
|
||||
let user = users.entry(session.user_id.clone()).or_insert(existing);
|
||||
if user.password_hash.as_deref() != Some(expected_password_hash)
|
||||
|| !user.auth_source.eq_ignore_ascii_case("local")
|
||||
|| !user.is_active
|
||||
|| user.is_deleted
|
||||
|| user.security_version != session.security_version
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let now = session
|
||||
.created_at
|
||||
.or(session.updated_at)
|
||||
.or(session.last_seen_at)
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
user.last_login_at = Some(now);
|
||||
let mut sessions = self
|
||||
.auth_session_store
|
||||
.as_ref()
|
||||
.expect("checked auth session store")
|
||||
.lock()
|
||||
.expect("auth session store should lock");
|
||||
for existing in sessions.values_mut() {
|
||||
if existing.user_id == session.user_id
|
||||
&& existing.client_device_id == session.client_device_id
|
||||
&& !existing.is_revoked()
|
||||
&& !existing.is_expired(now)
|
||||
{
|
||||
existing.revoked_at = Some(now);
|
||||
existing.revoke_reason = Some("replaced_by_new_login".to_string());
|
||||
existing.updated_at = Some(now);
|
||||
}
|
||||
}
|
||||
sessions.insert(
|
||||
format!("{}:{}", session.user_id, session.id),
|
||||
session.clone().into(),
|
||||
);
|
||||
return Ok(Some(session));
|
||||
}
|
||||
|
||||
let raw_session: crate::data::state::StoredUserSessionRecord = session.into();
|
||||
self.data
|
||||
.create_user_session_if_password_matches(&raw_session, expected_password_hash)
|
||||
.await
|
||||
.map(|value| value.map(Into::into))
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn rotate_user_session_refresh_token(
|
||||
&self,
|
||||
user_id: &str,
|
||||
session_id: &str,
|
||||
previous_refresh_token_hash: &str,
|
||||
expected_refresh_token_hash: &str,
|
||||
next_refresh_token_hash: &str,
|
||||
rotated_at: chrono::DateTime<chrono::Utc>,
|
||||
expires_at: chrono::DateTime<chrono::Utc>,
|
||||
@@ -171,8 +286,12 @@ impl AppState {
|
||||
if let Some(store) = self.auth_session_store.as_ref() {
|
||||
let key = format!("{user_id}:{session_id}");
|
||||
let mut guard = store.lock().expect("auth session store should lock");
|
||||
if let Some(session) = guard.get_mut(&key) {
|
||||
session.prev_refresh_token_hash = Some(previous_refresh_token_hash.to_string());
|
||||
if let Some(session) = guard.get_mut(&key).filter(|session| {
|
||||
session.refresh_token_hash == expected_refresh_token_hash
|
||||
&& !session.is_revoked()
|
||||
&& !session.is_expired(rotated_at)
|
||||
}) {
|
||||
session.prev_refresh_token_hash = Some(expected_refresh_token_hash.to_string());
|
||||
session.refresh_token_hash = next_refresh_token_hash.to_string();
|
||||
session.rotated_at = Some(rotated_at);
|
||||
session.expires_at = Some(expires_at);
|
||||
@@ -193,7 +312,7 @@ impl AppState {
|
||||
.rotate_user_session_refresh_token(
|
||||
user_id,
|
||||
session_id,
|
||||
previous_refresh_token_hash,
|
||||
expected_refresh_token_hash,
|
||||
next_refresh_token_hash,
|
||||
rotated_at,
|
||||
expires_at,
|
||||
|
||||
@@ -288,6 +288,22 @@ impl AppState {
|
||||
Ok(group)
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_user_group_if_matches(
|
||||
&self,
|
||||
expected: &aether_data::repository::users::StoredUserGroup,
|
||||
restored: &aether_data::repository::users::StoredUserGroup,
|
||||
) -> Result<bool, GatewayError> {
|
||||
let restored = self
|
||||
.data
|
||||
.restore_user_group_if_matches(expected, restored)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if restored {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_user_group(&self, group_id: &str) -> Result<bool, GatewayError> {
|
||||
let deleted = self
|
||||
.data
|
||||
@@ -369,6 +385,23 @@ impl AppState {
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_user_groups_if_matches(
|
||||
&self,
|
||||
user_id: &str,
|
||||
expected_group_ids: &[String],
|
||||
restored_group_ids: &[String],
|
||||
) -> Result<bool, GatewayError> {
|
||||
let restored = self
|
||||
.data
|
||||
.restore_user_groups_if_matches(user_id, expected_group_ids, restored_group_ids)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if restored {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
pub(crate) async fn add_user_to_group(
|
||||
&self,
|
||||
group_id: &str,
|
||||
@@ -434,7 +467,9 @@ impl AppState {
|
||||
pub(crate) async fn update_local_auth_user_profile(
|
||||
&self,
|
||||
user_id: &str,
|
||||
email_present: bool,
|
||||
email: Option<String>,
|
||||
email_verified: Option<bool>,
|
||||
username: Option<String>,
|
||||
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
|
||||
#[cfg(test)]
|
||||
@@ -457,8 +492,11 @@ impl AppState {
|
||||
let Some(mut user) = existing else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(email) = email {
|
||||
user.email = Some(email);
|
||||
if email_present {
|
||||
user.email = email;
|
||||
}
|
||||
if let Some(email_verified) = email_verified {
|
||||
user.email_verified = email_verified;
|
||||
}
|
||||
if let Some(username) = username {
|
||||
user.username = username;
|
||||
@@ -473,7 +511,7 @@ impl AppState {
|
||||
|
||||
let user = self
|
||||
.data
|
||||
.update_local_auth_user_profile(user_id, email, username)
|
||||
.update_local_auth_user_profile(user_id, email_present, email, email_verified, username)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if user.is_some() {
|
||||
@@ -482,6 +520,159 @@ impl AppState {
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn restore_local_auth_user_state_if_matches(
|
||||
&self,
|
||||
expected_auth: &aether_data::repository::users::StoredUserAuthRecord,
|
||||
restored_auth: &aether_data::repository::users::StoredUserAuthRecord,
|
||||
expected_export: &aether_data::repository::users::StoredUserExportRow,
|
||||
restored_export: &aether_data::repository::users::StoredUserExportRow,
|
||||
expected_model_capability_settings: Option<&serde_json::Value>,
|
||||
restored_model_capability_settings: Option<serde_json::Value>,
|
||||
expected_feature_settings: Option<&serde_json::Value>,
|
||||
restored_feature_settings: Option<serde_json::Value>,
|
||||
) -> Result<bool, GatewayError> {
|
||||
#[cfg(test)]
|
||||
if let Some(store) = self.auth_user_store.as_ref() {
|
||||
// The gateway test harness uses an auth overlay for users while most auxiliary data
|
||||
// remains in the repository. Keep the compare-and-write atomic for that overlay too;
|
||||
// otherwise import rollback tests would silently exercise a different, unconditional
|
||||
// path than production.
|
||||
let current_feature = self
|
||||
.data
|
||||
.read_user_feature_settings(&expected_auth.id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
let mut users = store.lock().expect("auth user store should lock");
|
||||
if users.contains_key(&expected_auth.id) {
|
||||
if expected_auth.id != restored_auth.id
|
||||
|| expected_export.id != expected_auth.id
|
||||
|| restored_export.id != restored_auth.id
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(current) = users.get(&expected_auth.id).cloned() else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !current.matches_restore_state(expected_auth) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let current_model =
|
||||
self.auth_user_model_capability_store
|
||||
.as_ref()
|
||||
.and_then(|settings| {
|
||||
settings
|
||||
.lock()
|
||||
.expect("auth user model capability store should lock")
|
||||
.get(&expected_auth.id)
|
||||
.cloned()
|
||||
});
|
||||
if current_model.as_ref() != expected_model_capability_settings {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Feature settings have no separate test overlay. When the backing repository has
|
||||
// a row, still honor the snapshot comparison; an absent row is represented by
|
||||
// `None`, which is the normal overlay case.
|
||||
if current_feature.as_ref() != expected_feature_settings {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let security_state_changed = current.role != restored_auth.role
|
||||
|| current.is_active != restored_auth.is_active;
|
||||
let removes_active_admin = current.role.eq_ignore_ascii_case("admin")
|
||||
&& current.is_active
|
||||
&& !current.is_deleted
|
||||
&& (!restored_auth.role.eq_ignore_ascii_case("admin")
|
||||
|| !restored_auth.is_active);
|
||||
if removes_active_admin
|
||||
&& users
|
||||
.values()
|
||||
.filter(|user| {
|
||||
user.role.eq_ignore_ascii_case("admin")
|
||||
&& user.is_active
|
||||
&& !user.is_deleted
|
||||
})
|
||||
.count()
|
||||
<= 1
|
||||
{
|
||||
return Err(GatewayError::LastActiveAdminUpdateDenied);
|
||||
}
|
||||
|
||||
let mut updated = restored_auth.clone();
|
||||
// Server-managed credentials and timestamps are deliberately not part of this
|
||||
// aggregate restore. The password has its own nullable CAS operation.
|
||||
updated.password_hash = current.password_hash;
|
||||
updated.security_version = current.security_version;
|
||||
updated.created_at = current.created_at;
|
||||
updated.last_login_at = current.last_login_at;
|
||||
updated.auth_source = current.auth_source;
|
||||
updated.is_deleted = current.is_deleted;
|
||||
if security_state_changed {
|
||||
updated.security_version =
|
||||
updated.security_version.checked_add(1).ok_or_else(|| {
|
||||
GatewayError::Internal("users.security_version overflow".to_string())
|
||||
})?;
|
||||
}
|
||||
users.insert(updated.id.clone(), updated.clone());
|
||||
drop(users);
|
||||
|
||||
if let Some(settings) = self.auth_user_model_capability_store.as_ref() {
|
||||
let mut guard = settings
|
||||
.lock()
|
||||
.expect("auth user model capability store should lock");
|
||||
match restored_model_capability_settings {
|
||||
Some(value) => {
|
||||
guard.insert(updated.id.clone(), value);
|
||||
}
|
||||
None => {
|
||||
guard.remove(&updated.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if security_state_changed {
|
||||
if let Some(sessions) = self.auth_session_store.as_ref() {
|
||||
let now = chrono::Utc::now();
|
||||
for session in sessions
|
||||
.lock()
|
||||
.expect("auth session store should lock")
|
||||
.values_mut()
|
||||
.filter(|session| {
|
||||
session.user_id == updated.id && session.revoked_at.is_none()
|
||||
})
|
||||
{
|
||||
session.revoked_at = Some(now);
|
||||
session.revoke_reason = Some("user_security_state_changed".to_string());
|
||||
session.updated_at = Some(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.invalidate_auth_context_cache();
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
let restored = self
|
||||
.data
|
||||
.restore_local_auth_user_state_if_matches(
|
||||
expected_auth,
|
||||
restored_auth,
|
||||
expected_export,
|
||||
restored_export,
|
||||
expected_model_capability_settings,
|
||||
restored_model_capability_settings,
|
||||
expected_feature_settings,
|
||||
restored_feature_settings,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if restored {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_local_auth_user_password_hash(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -509,6 +700,9 @@ impl AppState {
|
||||
return Ok(None);
|
||||
};
|
||||
user.password_hash = Some(password_hash);
|
||||
user.security_version = user.security_version.checked_add(1).ok_or_else(|| {
|
||||
GatewayError::Internal("users.security_version overflow".to_string())
|
||||
})?;
|
||||
store
|
||||
.lock()
|
||||
.expect("auth user store should lock")
|
||||
@@ -522,6 +716,193 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_local_auth_user_password_hash_if_matches(
|
||||
&self,
|
||||
user_id: &str,
|
||||
expected_password_hash: Option<&str>,
|
||||
password_hash: Option<String>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<bool, GatewayError> {
|
||||
#[cfg(test)]
|
||||
if let Some(store) = self.auth_user_store.as_ref() {
|
||||
let existing = {
|
||||
store
|
||||
.lock()
|
||||
.expect("auth user store should lock")
|
||||
.get(user_id)
|
||||
.cloned()
|
||||
};
|
||||
let existing = match existing {
|
||||
Some(user) => Some(user),
|
||||
None => self
|
||||
.data
|
||||
.find_user_auth_by_id(user_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?,
|
||||
};
|
||||
let Some(mut user) = existing else {
|
||||
return Ok(false);
|
||||
};
|
||||
if user.password_hash.as_deref() != expected_password_hash {
|
||||
return Ok(false);
|
||||
}
|
||||
user.password_hash = password_hash;
|
||||
user.security_version = user.security_version.checked_add(1).ok_or_else(|| {
|
||||
GatewayError::Internal("users.security_version overflow".to_string())
|
||||
})?;
|
||||
store
|
||||
.lock()
|
||||
.expect("auth user store should lock")
|
||||
.insert(user.id.clone(), user);
|
||||
self.invalidate_auth_context_cache();
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
self.data
|
||||
.restore_local_auth_user_password_hash_if_matches(
|
||||
user_id,
|
||||
expected_password_hash,
|
||||
password_hash,
|
||||
updated_at,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn reset_local_auth_user_password_and_revoke_sessions(
|
||||
&self,
|
||||
user_id: &str,
|
||||
password_hash: String,
|
||||
changed_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<bool, GatewayError> {
|
||||
#[cfg(test)]
|
||||
if let (Some(user_store), Some(session_store)) = (
|
||||
self.auth_user_store.as_ref(),
|
||||
self.auth_session_store.as_ref(),
|
||||
) {
|
||||
let existing = {
|
||||
user_store
|
||||
.lock()
|
||||
.expect("auth user store should lock")
|
||||
.get(user_id)
|
||||
.cloned()
|
||||
};
|
||||
let existing = match existing {
|
||||
Some(user) => Some(user),
|
||||
None => self
|
||||
.data
|
||||
.find_user_auth_by_id(user_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?,
|
||||
};
|
||||
let Some(existing) = existing.filter(|user| !user.is_deleted) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let mut users = user_store.lock().expect("auth user store should lock");
|
||||
let mut sessions = session_store
|
||||
.lock()
|
||||
.expect("auth session store should lock");
|
||||
let user = users.entry(user_id.to_string()).or_insert(existing);
|
||||
user.password_hash = Some(password_hash);
|
||||
user.security_version = user.security_version.checked_add(1).ok_or_else(|| {
|
||||
GatewayError::Internal("users.security_version overflow".to_string())
|
||||
})?;
|
||||
for session in sessions
|
||||
.values_mut()
|
||||
.filter(|session| session.user_id == user_id && !session.is_revoked())
|
||||
{
|
||||
session.revoked_at = Some(changed_at);
|
||||
session.revoke_reason = Some("admin_password_reset".to_string());
|
||||
session.updated_at = Some(changed_at);
|
||||
}
|
||||
self.invalidate_auth_context_cache();
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let reset = self
|
||||
.data
|
||||
.reset_local_auth_user_password_and_revoke_sessions(user_id, password_hash, changed_at)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if reset {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
Ok(reset)
|
||||
}
|
||||
|
||||
pub(crate) async fn change_local_auth_password_and_revoke_sessions(
|
||||
&self,
|
||||
user_id: &str,
|
||||
current_session_id: &str,
|
||||
expected_password_hash: Option<&str>,
|
||||
next_password_hash: String,
|
||||
changed_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<bool, GatewayError> {
|
||||
#[cfg(test)]
|
||||
if let (Some(user_store), Some(session_store)) = (
|
||||
self.auth_user_store.as_ref(),
|
||||
self.auth_session_store.as_ref(),
|
||||
) {
|
||||
let existing = {
|
||||
user_store
|
||||
.lock()
|
||||
.expect("auth user store should lock")
|
||||
.get(user_id)
|
||||
.cloned()
|
||||
};
|
||||
let existing = match existing {
|
||||
Some(user) => Some(user),
|
||||
None => self
|
||||
.data
|
||||
.find_user_auth_by_id(user_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?,
|
||||
};
|
||||
let Some(existing) = existing else {
|
||||
return Ok(false);
|
||||
};
|
||||
let mut users = user_store.lock().expect("auth user store should lock");
|
||||
let mut sessions = session_store
|
||||
.lock()
|
||||
.expect("auth session store should lock");
|
||||
let user = users.entry(user_id.to_string()).or_insert(existing);
|
||||
if user.password_hash.as_deref() != expected_password_hash {
|
||||
return Ok(false);
|
||||
}
|
||||
let current_key = format!("{user_id}:{current_session_id}");
|
||||
if !sessions
|
||||
.get(¤t_key)
|
||||
.is_some_and(|session| !session.is_revoked() && !session.is_expired(changed_at))
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
user.password_hash = Some(next_password_hash);
|
||||
user.security_version = user.security_version.checked_add(1).ok_or_else(|| {
|
||||
GatewayError::Internal("users.security_version overflow".to_string())
|
||||
})?;
|
||||
for session in sessions
|
||||
.values_mut()
|
||||
.filter(|session| session.user_id == user_id && !session.is_revoked())
|
||||
{
|
||||
session.revoked_at = Some(changed_at);
|
||||
session.revoke_reason = Some("password_changed".to_string());
|
||||
session.updated_at = Some(changed_at);
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
self.data
|
||||
.change_local_auth_password_and_revoke_sessions(
|
||||
user_id,
|
||||
current_session_id,
|
||||
expected_password_hash,
|
||||
next_password_hash,
|
||||
changed_at,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn create_local_auth_user(
|
||||
&self,
|
||||
email: Option<String>,
|
||||
@@ -642,10 +1023,18 @@ impl AppState {
|
||||
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
|
||||
#[cfg(test)]
|
||||
if let Some(store) = self.auth_user_store.as_ref() {
|
||||
let mut guard = store.lock().expect("auth user store should lock");
|
||||
let Some(user) = guard.get_mut(user_id) else {
|
||||
let mut users = store.lock().expect("auth user store should lock");
|
||||
let Some(user) = users.get_mut(user_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let security_state_changed = role
|
||||
.as_deref()
|
||||
.is_some_and(|next_role| !user.role.eq_ignore_ascii_case(next_role))
|
||||
|| is_active.is_some_and(|next_active| user.is_active != next_active);
|
||||
let mut sessions = self
|
||||
.auth_session_store
|
||||
.as_ref()
|
||||
.map(|sessions| sessions.lock().expect("auth session store should lock"));
|
||||
if let Some(role) = role {
|
||||
user.role = role;
|
||||
}
|
||||
@@ -661,9 +1050,26 @@ impl AppState {
|
||||
if let Some(is_active) = is_active {
|
||||
user.is_active = is_active;
|
||||
}
|
||||
if security_state_changed {
|
||||
user.security_version = user.security_version.checked_add(1).ok_or_else(|| {
|
||||
GatewayError::Internal("users.security_version overflow".to_string())
|
||||
})?;
|
||||
let revoked_at = chrono::Utc::now();
|
||||
if let Some(sessions) = sessions.as_mut() {
|
||||
for session in sessions
|
||||
.values_mut()
|
||||
.filter(|session| session.user_id == user_id && !session.is_revoked())
|
||||
{
|
||||
session.revoked_at = Some(revoked_at);
|
||||
session.revoke_reason = Some("user_security_state_changed".to_string());
|
||||
session.updated_at = Some(revoked_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = (rate_limit_present, rate_limit);
|
||||
let user = user.clone();
|
||||
drop(guard);
|
||||
drop(sessions);
|
||||
drop(users);
|
||||
self.invalidate_auth_context_cache();
|
||||
return Ok(Some(user));
|
||||
}
|
||||
@@ -684,7 +1090,13 @@ impl AppState {
|
||||
is_active,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
.map_err(|err| {
|
||||
if aether_data::repository::users::is_last_active_admin_update_denied(&err) {
|
||||
GatewayError::LastActiveAdminUpdateDenied
|
||||
} else {
|
||||
GatewayError::Internal(err.to_string())
|
||||
}
|
||||
})?;
|
||||
if user.is_some() {
|
||||
self.invalidate_auth_context_cache();
|
||||
}
|
||||
@@ -788,10 +1200,16 @@ impl AppState {
|
||||
self.data
|
||||
.delete_local_auth_user(user_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
.map_err(|err| {
|
||||
if aether_data::repository::users::is_last_active_admin_delete_denied(&err) {
|
||||
GatewayError::LastActiveAdminDeleteDenied
|
||||
} else {
|
||||
GatewayError::Internal(err.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn register_local_auth_user(
|
||||
pub(crate) async fn register_local_auth_user_with_wallet_outcome(
|
||||
&self,
|
||||
email: Option<String>,
|
||||
email_verified: bool,
|
||||
@@ -803,6 +1221,7 @@ impl AppState {
|
||||
Option<(
|
||||
aether_data::repository::users::StoredUserAuthRecord,
|
||||
aether_data::repository::wallet::StoredWalletSnapshot,
|
||||
bool,
|
||||
)>,
|
||||
GatewayError,
|
||||
> {
|
||||
@@ -868,11 +1287,17 @@ impl AppState {
|
||||
.lock()
|
||||
.expect("auth wallet store should lock")
|
||||
.insert(wallet.id.clone(), wallet.clone());
|
||||
return Ok(Some((user, wallet)));
|
||||
super::user_provisioning::record_test_initial_gift_transaction(
|
||||
self,
|
||||
&wallet,
|
||||
&user.id,
|
||||
"用户初始赠款",
|
||||
);
|
||||
return Ok(Some((user, wallet, true)));
|
||||
}
|
||||
|
||||
self.data
|
||||
.register_local_auth_user(
|
||||
.register_local_auth_user_with_wallet_outcome(
|
||||
email,
|
||||
email_verified,
|
||||
username,
|
||||
@@ -883,6 +1308,34 @@ impl AppState {
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn register_local_auth_user(
|
||||
&self,
|
||||
email: Option<String>,
|
||||
email_verified: bool,
|
||||
username: String,
|
||||
password_hash: String,
|
||||
initial_gift_usd: f64,
|
||||
unlimited: bool,
|
||||
) -> Result<
|
||||
Option<(
|
||||
aether_data::repository::users::StoredUserAuthRecord,
|
||||
aether_data::repository::wallet::StoredWalletSnapshot,
|
||||
)>,
|
||||
GatewayError,
|
||||
> {
|
||||
Ok(self
|
||||
.register_local_auth_user_with_wallet_outcome(
|
||||
email,
|
||||
email_verified,
|
||||
username,
|
||||
password_hash,
|
||||
initial_gift_usd,
|
||||
unlimited,
|
||||
)
|
||||
.await?
|
||||
.map(|(user, wallet, _created)| (user, wallet)))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_user_group_ids(group_ids: &[String]) -> BTreeSet<String> {
|
||||
@@ -944,6 +1397,7 @@ mod tests {
|
||||
local_rejection: None,
|
||||
allowed_models: Some(vec!["gpt-4.1".to_string()]),
|
||||
ip_rules: None,
|
||||
verified_api_key_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,12 @@ use super::{
|
||||
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
|
||||
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput, AppState,
|
||||
BillingPlanRecord, BillingPlanWriteInput, GatewayError, LocalMutationOutcome,
|
||||
PaymentGatewayConfigRecord, PaymentGatewayConfigWriteInput, UserDailyQuotaAvailabilityRecord,
|
||||
UserPlanEntitlementRecord,
|
||||
PaymentGatewayConfigCasWriteInput, PaymentGatewayConfigRecord, PaymentGatewayConfigWriteInput,
|
||||
PaymentGatewaySecretCasUpdate, UserDailyQuotaAvailabilityRecord, UserPlanEntitlementRecord,
|
||||
};
|
||||
|
||||
const PAYMENT_GATEWAY_SECRET_MIGRATION_MAX_ATTEMPTS: usize = 8;
|
||||
|
||||
fn data_error(err: impl ToString) -> GatewayError {
|
||||
GatewayError::Internal(err.to_string())
|
||||
}
|
||||
@@ -446,9 +448,73 @@ impl AppState {
|
||||
&self,
|
||||
provider: &str,
|
||||
) -> Result<Option<PaymentGatewayConfigRecord>, GatewayError> {
|
||||
self.data
|
||||
.find_payment_gateway_config(provider)
|
||||
let provider = provider.trim().to_ascii_lowercase();
|
||||
let mut record = self
|
||||
.data
|
||||
.find_payment_gateway_config(&provider)
|
||||
.await
|
||||
.map_err(data_error)?;
|
||||
for _ in 0..PAYMENT_GATEWAY_SECRET_MIGRATION_MAX_ATTEMPTS {
|
||||
let Some(mut current) = record else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(observed) = current.merchant_key_encrypted.as_deref() else {
|
||||
return Ok(Some(current));
|
||||
};
|
||||
let projection = crate::handlers::shared::open_payment_gateway_secret(
|
||||
self,
|
||||
&crate::handlers::shared::PaymentGatewaySecretBinding::from_record(¤t)
|
||||
.map_err(|detail| {
|
||||
GatewayError::Internal(format!(
|
||||
"payment gateway secret binding is invalid for {}: {detail}",
|
||||
current.provider
|
||||
))
|
||||
})?,
|
||||
observed,
|
||||
)
|
||||
.map_err(|detail| {
|
||||
GatewayError::Internal(format!(
|
||||
"payment gateway secret integrity check failed for {}: {detail}",
|
||||
current.provider
|
||||
))
|
||||
})?;
|
||||
if !projection.migration_required {
|
||||
return Ok(Some(current));
|
||||
}
|
||||
|
||||
let update = PaymentGatewaySecretCasUpdate {
|
||||
provider: current.provider.clone(),
|
||||
expected_merchant_key_encrypted: observed.to_string(),
|
||||
merchant_key_encrypted: projection.protected.clone(),
|
||||
};
|
||||
if self
|
||||
.data
|
||||
.compare_and_swap_payment_gateway_secret(&update)
|
||||
.await
|
||||
.map_err(data_error)?
|
||||
{
|
||||
current.merchant_key_encrypted = Some(projection.protected);
|
||||
return Ok(Some(current));
|
||||
}
|
||||
record = self
|
||||
.data
|
||||
.find_payment_gateway_config_strong(&provider)
|
||||
.await
|
||||
.map_err(data_error)?;
|
||||
}
|
||||
Err(GatewayError::Internal(format!(
|
||||
"payment gateway secret migration did not converge for {provider}"
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) async fn compare_and_swap_payment_gateway_config(
|
||||
&self,
|
||||
input: &PaymentGatewayConfigCasWriteInput,
|
||||
) -> Result<LocalMutationOutcome<PaymentGatewayConfigRecord>, GatewayError> {
|
||||
self.data
|
||||
.compare_and_swap_payment_gateway_config(input)
|
||||
.await
|
||||
.map(local_mutation_outcome)
|
||||
.map_err(data_error)
|
||||
}
|
||||
|
||||
@@ -608,13 +674,20 @@ impl AppState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||
use aether_data::repository::billing::InMemoryBillingReadRepository;
|
||||
use aether_data_contracts::repository::billing::{
|
||||
BillingReadRepository, PaymentGatewayConfigWriteInput,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
AdminBillingCollectorWriteInput, AdminBillingRuleWriteInput, AppState, LocalMutationOutcome,
|
||||
};
|
||||
use crate::data::GatewayDataState;
|
||||
|
||||
const CACHE_KEY: &str = "billing-mutation-test";
|
||||
|
||||
@@ -736,4 +809,59 @@ mod tests {
|
||||
.get(&CACHE_KEY.to_string(), Duration::from_secs(60),)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_lookup_lazily_migrates_legacy_secret_without_touching_other_fields() {
|
||||
let repository = Arc::new(InMemoryBillingReadRepository::default());
|
||||
let legacy = encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"secret_key":"legacy-value"}"#,
|
||||
)
|
||||
.expect("legacy secret should encrypt");
|
||||
repository
|
||||
.upsert_payment_gateway_config(&PaymentGatewayConfigWriteInput {
|
||||
provider: "stripe".to_string(),
|
||||
enabled: true,
|
||||
endpoint_url: "https://api.stripe.com".to_string(),
|
||||
callback_base_url: Some("https://example.com".to_string()),
|
||||
merchant_id: "merchant".to_string(),
|
||||
merchant_key_encrypted: Some(legacy),
|
||||
preserve_existing_secret: false,
|
||||
pay_currency: "USD".to_string(),
|
||||
usd_exchange_rate: 1.0,
|
||||
min_recharge_usd: 1.0,
|
||||
channels_json: json!({"channels": []}),
|
||||
})
|
||||
.await
|
||||
.expect("gateway seed should succeed");
|
||||
let before = repository
|
||||
.find_payment_gateway_config("stripe")
|
||||
.await
|
||||
.expect("gateway lookup should succeed")
|
||||
.expect("gateway should exist");
|
||||
let data = GatewayDataState::with_billing_reader_for_tests(repository.clone())
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
|
||||
let state = AppState::new()
|
||||
.expect("app state should build")
|
||||
.with_data_state_for_tests(data);
|
||||
|
||||
let migrated = state
|
||||
.find_payment_gateway_config("stripe")
|
||||
.await
|
||||
.expect("gateway migration should succeed")
|
||||
.expect("gateway should exist");
|
||||
assert!(migrated
|
||||
.merchant_key_encrypted
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.starts_with("aether-payment-gateway-secret-v3:")));
|
||||
let stored = repository
|
||||
.find_payment_gateway_config("stripe")
|
||||
.await
|
||||
.expect("gateway lookup should succeed")
|
||||
.expect("gateway should exist");
|
||||
assert_eq!(stored, migrated);
|
||||
assert_eq!(stored.updated_at_unix_secs, before.updated_at_unix_secs);
|
||||
assert_eq!(stored.endpoint_url, before.endpoint_url);
|
||||
assert_eq!(stored.channels_json, before.channels_json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ impl AppState {
|
||||
let effective_status = if order.status == "pending"
|
||||
&& order
|
||||
.expires_at_unix_secs
|
||||
.is_some_and(|value| value < now_unix_secs)
|
||||
.is_some_and(|value| value <= now_unix_secs)
|
||||
{
|
||||
"expired"
|
||||
} else {
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::super::{
|
||||
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
|
||||
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput, AppState,
|
||||
BillingPlanRecord, BillingPlanWriteInput, GatewayError, LocalMutationOutcome,
|
||||
PaymentGatewayConfigRecord, PaymentGatewayConfigWriteInput, UserDailyQuotaAvailabilityRecord,
|
||||
UserPlanEntitlementRecord,
|
||||
PaymentGatewayConfigCasWriteInput, PaymentGatewayConfigRecord, PaymentGatewayConfigWriteInput,
|
||||
PaymentGatewaySecretCasUpdate, UserDailyQuotaAvailabilityRecord, UserPlanEntitlementRecord,
|
||||
};
|
||||
|
||||
mod admin;
|
||||
|
||||
@@ -111,8 +111,9 @@ impl AppState {
|
||||
|
||||
pub(crate) async fn upsert_request_candidate(
|
||||
&self,
|
||||
candidate: candidates::UpsertRequestCandidateRecord,
|
||||
mut candidate: candidates::UpsertRequestCandidateRecord,
|
||||
) -> Result<Option<candidates::StoredRequestCandidate>, GatewayError> {
|
||||
candidate.sanitize_for_persistence();
|
||||
if let Some(queue) = self.request_candidate_queue.as_ref() {
|
||||
let stored = stored_request_candidate_from_upsert(&candidate)?;
|
||||
queue
|
||||
@@ -136,8 +137,9 @@ impl AppState {
|
||||
/// the async queue is enabled.
|
||||
pub(crate) async fn enqueue_request_candidate_status(
|
||||
&self,
|
||||
candidate: candidates::UpsertRequestCandidateRecord,
|
||||
mut candidate: candidates::UpsertRequestCandidateRecord,
|
||||
) -> Result<Option<()>, GatewayError> {
|
||||
candidate.sanitize_for_persistence();
|
||||
if let Some(queue) = self.request_candidate_queue.as_ref() {
|
||||
queue
|
||||
.enqueue_or_fallback(candidate)
|
||||
@@ -158,8 +160,9 @@ impl AppState {
|
||||
/// when the queue is disabled or closed.
|
||||
pub(crate) fn try_enqueue_request_candidate_status(
|
||||
&self,
|
||||
candidate: candidates::UpsertRequestCandidateRecord,
|
||||
mut candidate: candidates::UpsertRequestCandidateRecord,
|
||||
) -> Result<(), candidates::UpsertRequestCandidateRecord> {
|
||||
candidate.sanitize_for_persistence();
|
||||
let Some(queue) = self.request_candidate_queue.as_ref() else {
|
||||
return Err(candidate);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,19 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_gemini_file_mapping_if_owner_matches(
|
||||
&self,
|
||||
record: aether_data::repository::gemini_file_mappings::UpsertGeminiFileMappingRecord,
|
||||
) -> Result<
|
||||
Option<aether_data::repository::gemini_file_mappings::StoredGeminiFileMapping>,
|
||||
GatewayError,
|
||||
> {
|
||||
self.data
|
||||
.upsert_gemini_file_mapping_if_owner_matches(record)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_gemini_file_mappings(
|
||||
&self,
|
||||
query: &aether_data::repository::gemini_file_mappings::GeminiFileMappingListQuery,
|
||||
@@ -27,6 +40,50 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn find_gemini_file_mapping_by_file_name(
|
||||
&self,
|
||||
file_name: &str,
|
||||
) -> Result<
|
||||
Option<aether_data::repository::gemini_file_mappings::StoredGeminiFileMapping>,
|
||||
GatewayError,
|
||||
> {
|
||||
self.data
|
||||
.find_gemini_file_mapping_by_file_name(file_name)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn find_active_gemini_file_mapping_for_user(
|
||||
&self,
|
||||
file_name: &str,
|
||||
user_id: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<
|
||||
Option<aether_data::repository::gemini_file_mappings::StoredGeminiFileMapping>,
|
||||
GatewayError,
|
||||
> {
|
||||
self.data
|
||||
.find_active_gemini_file_mapping_for_user(file_name, user_id, now_unix_secs)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn find_active_gemini_file_mapping_for_owner(
|
||||
&self,
|
||||
file_name: &str,
|
||||
key_id: &str,
|
||||
user_id: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<
|
||||
Option<aether_data::repository::gemini_file_mappings::StoredGeminiFileMapping>,
|
||||
GatewayError,
|
||||
> {
|
||||
self.data
|
||||
.find_active_gemini_file_mapping_for_owner(file_name, key_id, user_id, now_unix_secs)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn summarize_gemini_file_mappings(
|
||||
&self,
|
||||
now_unix_secs: u64,
|
||||
@@ -48,6 +105,29 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_gemini_file_mapping_by_file_name_for_user(
|
||||
&self,
|
||||
file_name: &str,
|
||||
user_id: &str,
|
||||
) -> Result<bool, GatewayError> {
|
||||
self.data
|
||||
.delete_gemini_file_mapping_by_file_name_for_user(file_name, user_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_gemini_file_mapping_by_file_name_for_owner(
|
||||
&self,
|
||||
file_name: &str,
|
||||
key_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<bool, GatewayError> {
|
||||
self.data
|
||||
.delete_gemini_file_mapping_by_file_name_for_owner(file_name, key_id, user_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_gemini_file_mapping_by_id(
|
||||
&self,
|
||||
mapping_id: &str,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_admin::observability::usage::admin_usage_safe_metadata_value;
|
||||
use aether_data::repository::audit::AuditLogListQuery;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::{json, Value};
|
||||
@@ -43,8 +44,8 @@ impl AppState {
|
||||
"description": record.description,
|
||||
"ip_address": record.ip_address,
|
||||
"status_code": record.status_code,
|
||||
"error_message": record.error_message,
|
||||
"metadata": record.metadata,
|
||||
"error_message": record.error_message.as_ref().map(|_| "audit_event_failed"),
|
||||
"metadata": sanitize_admin_audit_metadata(record.metadata.as_ref()),
|
||||
"created_at": record.created_at_rfc3339(),
|
||||
})
|
||||
})
|
||||
@@ -72,7 +73,7 @@ impl AppState {
|
||||
"user_id": record.user_id,
|
||||
"description": record.description,
|
||||
"ip_address": record.ip_address,
|
||||
"metadata": record.metadata,
|
||||
"metadata": sanitize_admin_audit_metadata(record.metadata.as_ref()),
|
||||
"created_at": record.created_at_rfc3339(),
|
||||
})
|
||||
})
|
||||
@@ -134,3 +135,41 @@ impl AppState {
|
||||
fn cutoff_unix_secs(cutoff_time: DateTime<Utc>) -> u64 {
|
||||
cutoff_time.timestamp().max(0) as u64
|
||||
}
|
||||
|
||||
fn sanitize_admin_audit_metadata(metadata: Option<&Value>) -> Value {
|
||||
metadata
|
||||
.map(admin_usage_safe_metadata_value)
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sanitize_admin_audit_metadata;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn admin_audit_metadata_drops_credentials_and_url_components() {
|
||||
let metadata = sanitize_admin_audit_metadata(Some(&json!({
|
||||
"category": "security",
|
||||
"authorization": "Bearer audit-secret",
|
||||
"nested": {
|
||||
"refresh_token": "refresh-secret",
|
||||
"endpoint_url": "https://user:[email protected]/v1?token=query-secret#fragment",
|
||||
"safe_count": 2
|
||||
}
|
||||
})));
|
||||
|
||||
assert_eq!(metadata["category"], "security");
|
||||
assert!(metadata.get("authorization").is_none());
|
||||
assert!(metadata["nested"].get("refresh_token").is_none());
|
||||
assert_eq!(
|
||||
metadata["nested"]["endpoint_url"],
|
||||
"https://example.test/v1"
|
||||
);
|
||||
assert_eq!(metadata["nested"]["safe_count"], 2);
|
||||
let encoded = metadata.to_string();
|
||||
for secret in ["audit-secret", "refresh-secret", "password", "query-secret"] {
|
||||
assert!(!encoded.contains(secret), "leaked {secret}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ impl AppState {
|
||||
}
|
||||
if order
|
||||
.expires_at_unix_secs
|
||||
.is_some_and(|value| value < chrono::Utc::now().timestamp().max(0) as u64)
|
||||
.is_some_and(|value| value <= chrono::Utc::now().timestamp().max(0) as u64)
|
||||
{
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"payment order expired".to_string(),
|
||||
|
||||
@@ -4,13 +4,39 @@ use crate::data::state::{
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::http::StatusCode;
|
||||
use tracing::warn;
|
||||
|
||||
const REFERRAL_INVALID_INPUT_FALLBACK: &str = "返利请求无效";
|
||||
|
||||
fn safe_referral_invalid_input_detail(detail: &str) -> &'static str {
|
||||
// These messages are deliberate domain-level validation responses. Any
|
||||
// future adapter/storage detail must stay server-side instead of becoming
|
||||
// an oracle for database state or schema information.
|
||||
match detail {
|
||||
"邀请码无效" => "邀请码无效",
|
||||
"不能使用自己的邀请码注册" => "不能使用自己的邀请码注册",
|
||||
"仅失败返利可以补发" => "仅失败返利可以补发",
|
||||
"返利金额无效,无法补发" => "返利金额无效,无法补发",
|
||||
_ => REFERRAL_INVALID_INPUT_FALLBACK,
|
||||
}
|
||||
}
|
||||
|
||||
fn referral_data_error(err: aether_data::DataLayerError) -> GatewayError {
|
||||
match err {
|
||||
aether_data::DataLayerError::InvalidInput(detail) => GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: detail,
|
||||
},
|
||||
aether_data::DataLayerError::InvalidInput(detail) => {
|
||||
let safe_detail = safe_referral_invalid_input_detail(&detail);
|
||||
if safe_detail == REFERRAL_INVALID_INPUT_FALLBACK {
|
||||
warn!(
|
||||
event_name = "referral_invalid_input_hidden",
|
||||
error_length = detail.len(),
|
||||
"referral data-layer validation detail hidden from client"
|
||||
);
|
||||
}
|
||||
GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: safe_detail.to_string(),
|
||||
}
|
||||
}
|
||||
other => GatewayError::Internal(other.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -51,6 +77,13 @@ fn config_f64(value: Option<&serde_json::Value>, default: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn config_percent(value: Option<&serde_json::Value>) -> f64 {
|
||||
let value = config_f64(value, 0.0);
|
||||
(value.is_finite() && value > 0.0 && value <= 100.0)
|
||||
.then_some(value)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub(crate) fn has_referral_data_backend(&self) -> bool {
|
||||
self.data.has_referral_data_backend()
|
||||
@@ -93,7 +126,7 @@ impl AppState {
|
||||
config_string(headcount_trigger.as_ref()).unwrap_or_else(|| "registration".to_string());
|
||||
Ok(Some(ReferralRewardConfig {
|
||||
percent_enabled: matches!(mode.as_str(), "percent" | "both"),
|
||||
percent_rate: config_f64(percent.as_ref(), 0.0),
|
||||
percent_rate: config_percent(percent.as_ref()),
|
||||
headcount_enabled: matches!(mode.as_str(), "headcount" | "both"),
|
||||
headcount_amount_usd: config_f64(headcount_amount.as_ref(), 0.0),
|
||||
headcount_trigger,
|
||||
@@ -238,4 +271,43 @@ impl AppState {
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn reconcile_referral_rewards_once(
|
||||
&self,
|
||||
) -> Result<crate::data::state::ReferralReconciliationSummary, GatewayError> {
|
||||
let config = self.referral_reward_config().await?;
|
||||
self.data
|
||||
.reconcile_referral_rewards_once(config)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{referral_data_error, REFERRAL_INVALID_INPUT_FALLBACK};
|
||||
|
||||
#[test]
|
||||
fn referral_invalid_input_projection_allowlists_domain_messages() {
|
||||
let known = super::referral_data_error(aether_data::DataLayerError::InvalidInput(
|
||||
"邀请码无效".to_string(),
|
||||
));
|
||||
match known {
|
||||
crate::GatewayError::Client { message, .. } => assert_eq!(message, "邀请码无效"),
|
||||
other => panic!("expected client error, got {other:?}"),
|
||||
}
|
||||
|
||||
let secret = "database table referral_rewards row reward-secret has invalid wallet";
|
||||
let unknown = referral_data_error(aether_data::DataLayerError::InvalidInput(
|
||||
secret.to_string(),
|
||||
));
|
||||
match unknown {
|
||||
crate::GatewayError::Client { message, .. } => {
|
||||
assert_eq!(message, REFERRAL_INVALID_INPUT_FALLBACK);
|
||||
assert!(!message.contains("reward-secret"));
|
||||
assert!(!message.contains("referral_rewards"));
|
||||
}
|
||||
other => panic!("expected client error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use aether_data::repository::wallet::{
|
||||
AdjustWalletBalanceInput, CompleteAdminWalletRefundInput, CreateManualWalletRechargeInput,
|
||||
CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome, CreateWalletRechargeOrderInput,
|
||||
AdjustWalletBalanceInput, CompareAndSwapPaymentOrderStripeClientSecretInput,
|
||||
CompleteAdminWalletRefundInput, CreateManualWalletRechargeInput, CreatePlanPurchaseOrderInput,
|
||||
CreatePlanPurchaseOrderOutcome, CreateWalletRechargeOrderInput,
|
||||
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
|
||||
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput, FailAdminWalletRefundInput,
|
||||
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
||||
WalletMutationOutcome,
|
||||
FailWalletRechargeCheckoutInput, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
|
||||
ProcessPaymentCallbackOutcome, ReclaimWalletRechargeCheckoutInput,
|
||||
UpdateAdminWalletRefundGatewayInput, UpdateWalletRechargeCheckoutInput, WalletMutationOutcome,
|
||||
};
|
||||
|
||||
use crate::{AppState, GatewayError};
|
||||
@@ -20,6 +22,55 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn update_wallet_recharge_checkout(
|
||||
&self,
|
||||
input: UpdateWalletRechargeCheckoutInput,
|
||||
) -> Result<
|
||||
Option<WalletMutationOutcome<aether_data::repository::wallet::StoredAdminPaymentOrder>>,
|
||||
GatewayError,
|
||||
> {
|
||||
self.data
|
||||
.update_wallet_recharge_checkout(input)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn compare_and_swap_payment_order_stripe_client_secret(
|
||||
&self,
|
||||
input: CompareAndSwapPaymentOrderStripeClientSecretInput,
|
||||
) -> Result<Option<bool>, GatewayError> {
|
||||
self.data
|
||||
.compare_and_swap_payment_order_stripe_client_secret(input)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn fail_wallet_recharge_checkout(
|
||||
&self,
|
||||
input: FailWalletRechargeCheckoutInput,
|
||||
) -> Result<
|
||||
Option<WalletMutationOutcome<aether_data::repository::wallet::StoredAdminPaymentOrder>>,
|
||||
GatewayError,
|
||||
> {
|
||||
self.data
|
||||
.fail_wallet_recharge_checkout(input)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn reclaim_wallet_recharge_checkout(
|
||||
&self,
|
||||
input: ReclaimWalletRechargeCheckoutInput,
|
||||
) -> Result<
|
||||
Option<WalletMutationOutcome<aether_data::repository::wallet::StoredAdminPaymentOrder>>,
|
||||
GatewayError,
|
||||
> {
|
||||
self.data
|
||||
.reclaim_wallet_recharge_checkout(input)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn create_plan_purchase_order(
|
||||
&self,
|
||||
input: CreatePlanPurchaseOrderInput,
|
||||
@@ -134,6 +185,19 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn update_admin_wallet_refund_gateway(
|
||||
&self,
|
||||
input: UpdateAdminWalletRefundGatewayInput,
|
||||
) -> Result<
|
||||
Option<WalletMutationOutcome<aether_data::repository::wallet::StoredAdminWalletRefund>>,
|
||||
GatewayError,
|
||||
> {
|
||||
self.data
|
||||
.update_admin_wallet_refund_gateway(input)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn fail_admin_wallet_refund(
|
||||
&self,
|
||||
input: FailAdminWalletRefundInput,
|
||||
|
||||
@@ -191,6 +191,18 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn find_wallet_recharge_order_by_order_no(
|
||||
&self,
|
||||
user_id: &str,
|
||||
order_no: &str,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredAdminPaymentOrder>, GatewayError>
|
||||
{
|
||||
self.data
|
||||
.find_wallet_recharge_order_by_order_no(user_id, order_no)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn find_pending_plan_purchase_order_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -203,6 +215,28 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn find_payment_order_by_order_no(
|
||||
&self,
|
||||
order_no: &str,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredAdminPaymentOrder>, GatewayError>
|
||||
{
|
||||
self.data
|
||||
.find_payment_order_by_order_no(order_no)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn find_payment_order_by_id(
|
||||
&self,
|
||||
order_id: &str,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredAdminPaymentOrder>, GatewayError>
|
||||
{
|
||||
self.data
|
||||
.find_admin_payment_order(order_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn find_wallet_refund(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
|
||||
@@ -2,6 +2,9 @@ use super::{
|
||||
AdminWalletMutationOutcome, AdminWalletRefundRecord, AdminWalletTransactionRecord, AppState,
|
||||
GatewayError,
|
||||
};
|
||||
use aether_data::repository::wallet::{
|
||||
payment_order_refund_amounts_are_consistent, wallet_refund_proof_is_success,
|
||||
};
|
||||
|
||||
impl AppState {
|
||||
pub(crate) async fn admin_process_wallet_refund(
|
||||
@@ -39,6 +42,11 @@ impl AppState {
|
||||
else {
|
||||
return Ok(AdminWalletMutationOutcome::NotFound);
|
||||
};
|
||||
if !refund.amount_usd.is_finite() || refund.amount_usd <= 0.0 {
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"refund amount must be finite and greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
if !matches!(refund.status.as_str(), "approved" | "pending_approval") {
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"refund status is not approvable".to_string(),
|
||||
@@ -49,8 +57,26 @@ impl AppState {
|
||||
let mut updated_wallet = wallet.clone();
|
||||
let before_recharge = updated_wallet.balance;
|
||||
let before_gift = updated_wallet.gift_balance;
|
||||
let before_total_refunded = updated_wallet.total_refunded;
|
||||
let before_total = before_recharge + before_gift;
|
||||
let after_recharge = before_recharge - amount_usd;
|
||||
let after_total = after_recharge + before_gift;
|
||||
let after_total_refunded = before_total_refunded + amount_usd;
|
||||
if !before_recharge.is_finite()
|
||||
|| before_recharge < 0.0
|
||||
|| !before_gift.is_finite()
|
||||
|| before_gift < 0.0
|
||||
|| !before_total_refunded.is_finite()
|
||||
|| before_total_refunded < 0.0
|
||||
|| !before_total.is_finite()
|
||||
|| !after_recharge.is_finite()
|
||||
|| !after_total.is_finite()
|
||||
|| !after_total_refunded.is_finite()
|
||||
{
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"wallet balance is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
if after_recharge < 0.0 {
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"refund amount exceeds refundable recharge balance".to_string(),
|
||||
@@ -72,20 +98,41 @@ impl AppState {
|
||||
"payment order not found".to_string(),
|
||||
));
|
||||
};
|
||||
if amount_usd > order.refundable_amount_usd {
|
||||
if order.wallet_id != wallet_id || order.status != "credited" {
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"refund amount exceeds refundable amount".to_string(),
|
||||
"payment order is not refundable for this wallet".to_string(),
|
||||
));
|
||||
}
|
||||
let order_amount = order.amount_usd;
|
||||
let refunded_before = order.refunded_amount_usd;
|
||||
let refundable_before = order.refundable_amount_usd;
|
||||
let refunded_after = refunded_before + amount_usd;
|
||||
let refundable_after = refundable_before - amount_usd;
|
||||
if !payment_order_refund_amounts_are_consistent(
|
||||
order_amount,
|
||||
refunded_before,
|
||||
refundable_before,
|
||||
) || !refunded_after.is_finite()
|
||||
|| !refundable_after.is_finite()
|
||||
|| amount_usd > refundable_before
|
||||
|| refunded_after < 0.0
|
||||
|| refunded_after > order_amount
|
||||
|| refundable_after < 0.0
|
||||
|| refundable_after > order_amount
|
||||
{
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"payment order refund amounts are invalid".to_string(),
|
||||
));
|
||||
}
|
||||
let mut order = order;
|
||||
order.refunded_amount_usd += amount_usd;
|
||||
order.refundable_amount_usd -= amount_usd;
|
||||
order.refunded_amount_usd = refunded_after;
|
||||
order.refundable_amount_usd = refundable_after;
|
||||
updated_order = Some(order);
|
||||
}
|
||||
|
||||
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
|
||||
updated_wallet.balance = after_recharge;
|
||||
updated_wallet.total_refunded = (updated_wallet.total_refunded + amount_usd).max(0.0);
|
||||
updated_wallet.total_refunded = after_total_refunded;
|
||||
updated_wallet.updated_at_unix_secs = now_unix_secs;
|
||||
|
||||
let transaction = AdminWalletTransactionRecord {
|
||||
@@ -95,7 +142,7 @@ impl AppState {
|
||||
reason_code: "refund_out".to_string(),
|
||||
amount: -amount_usd,
|
||||
balance_before: before_total,
|
||||
balance_after: after_recharge + before_gift,
|
||||
balance_after: after_total,
|
||||
recharge_balance_before: before_recharge,
|
||||
recharge_balance_after: after_recharge,
|
||||
gift_balance_before: before_gift,
|
||||
@@ -130,6 +177,12 @@ impl AppState {
|
||||
.expect("admin wallet payment order store should lock")
|
||||
.insert(updated_order.id.clone(), updated_order);
|
||||
}
|
||||
if let Some(transaction_store) = self.admin_wallet_transaction_store.as_ref() {
|
||||
transaction_store
|
||||
.lock()
|
||||
.expect("admin wallet transaction store should lock")
|
||||
.insert(transaction.id.clone(), transaction.clone());
|
||||
}
|
||||
|
||||
self.invalidate_auth_context_cache();
|
||||
return Ok(AdminWalletMutationOutcome::Applied((
|
||||
@@ -187,6 +240,23 @@ impl AppState {
|
||||
else {
|
||||
return Ok(AdminWalletMutationOutcome::NotFound);
|
||||
};
|
||||
if !refund.amount_usd.is_finite() || refund.amount_usd <= 0.0 {
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"refund amount must be finite and greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
if let (Some(existing_id), Some(incoming_id)) =
|
||||
(refund.gateway_refund_id.as_deref(), gateway_refund_id)
|
||||
{
|
||||
if existing_id != incoming_id {
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"gateway refund identifier conflicts with existing evidence".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if refund.status == "succeeded" {
|
||||
return Ok(AdminWalletMutationOutcome::Applied(refund));
|
||||
}
|
||||
if refund.status != "processing" {
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"refund status must be processing before completion".to_string(),
|
||||
@@ -195,9 +265,22 @@ impl AppState {
|
||||
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
|
||||
let mut updated_refund = refund;
|
||||
updated_refund.status = "succeeded".to_string();
|
||||
updated_refund.gateway_refund_id = gateway_refund_id.map(ToOwned::to_owned);
|
||||
updated_refund.payout_reference = payout_reference.map(ToOwned::to_owned);
|
||||
updated_refund.payout_proof = payout_proof;
|
||||
updated_refund.gateway_refund_id = gateway_refund_id
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| updated_refund.gateway_refund_id.clone());
|
||||
updated_refund.payout_reference = payout_reference
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| updated_refund.payout_reference.clone());
|
||||
// Keep the durable provider response on ordinary retries. A
|
||||
// terminal success proof is the only completion payload allowed
|
||||
// to upgrade an earlier processing proof.
|
||||
if updated_refund.payout_proof.is_none()
|
||||
|| payout_proof
|
||||
.as_ref()
|
||||
.is_some_and(wallet_refund_proof_is_success)
|
||||
{
|
||||
updated_refund.payout_proof = payout_proof;
|
||||
}
|
||||
updated_refund.completed_at_unix_secs = Some(now_unix_secs);
|
||||
updated_refund.updated_at_unix_secs = now_unix_secs;
|
||||
refund_store
|
||||
@@ -269,6 +352,12 @@ impl AppState {
|
||||
return Ok(AdminWalletMutationOutcome::NotFound);
|
||||
};
|
||||
|
||||
if !refund.amount_usd.is_finite() || refund.amount_usd <= 0.0 {
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"refund amount must be finite and greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
|
||||
if matches!(refund.status.as_str(), "pending_approval" | "approved") {
|
||||
let mut updated_refund = refund;
|
||||
@@ -292,15 +381,95 @@ impl AppState {
|
||||
)));
|
||||
}
|
||||
|
||||
// The in-memory implementation mirrors the database contract:
|
||||
// only an explicitly offline payout without external evidence may
|
||||
// release its reservation.
|
||||
if refund.gateway_refund_id.is_some()
|
||||
|| refund.payout_proof.is_some()
|
||||
|| !refund
|
||||
.refund_mode
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("offline_payout")
|
||||
{
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"cannot fail refund while gateway settlement is processing".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let amount_usd = refund.amount_usd;
|
||||
let before_recharge = wallet.balance;
|
||||
let before_gift = wallet.gift_balance;
|
||||
let before_total_refunded = wallet.total_refunded;
|
||||
let before_total = before_recharge + before_gift;
|
||||
let after_recharge = before_recharge + amount_usd;
|
||||
let after_total = after_recharge + before_gift;
|
||||
let after_total_refunded = before_total_refunded - amount_usd;
|
||||
if !before_recharge.is_finite()
|
||||
|| before_recharge < 0.0
|
||||
|| !before_gift.is_finite()
|
||||
|| before_gift < 0.0
|
||||
|| !before_total_refunded.is_finite()
|
||||
|| before_total_refunded < 0.0
|
||||
|| before_total_refunded < amount_usd
|
||||
|| !before_total.is_finite()
|
||||
|| !after_recharge.is_finite()
|
||||
|| !after_total.is_finite()
|
||||
|| !after_total_refunded.is_finite()
|
||||
|| after_total_refunded < 0.0
|
||||
{
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"wallet balance is invalid for refund recovery".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut updated_order = None;
|
||||
if let Some(payment_order_id) = refund.payment_order_id.clone() {
|
||||
let Some(order_store) = self.admin_wallet_payment_order_store.as_ref() else {
|
||||
return Ok(AdminWalletMutationOutcome::Unavailable);
|
||||
};
|
||||
let Some(order) = order_store
|
||||
.lock()
|
||||
.expect("admin wallet payment order store should lock")
|
||||
.get(&payment_order_id)
|
||||
.cloned()
|
||||
else {
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"payment order not found".to_string(),
|
||||
));
|
||||
};
|
||||
if order.wallet_id != wallet_id || order.status != "credited" {
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"payment order is not refundable for this wallet".to_string(),
|
||||
));
|
||||
}
|
||||
let refunded_before = order.refunded_amount_usd;
|
||||
let refundable_before = order.refundable_amount_usd;
|
||||
let refunded_after = refunded_before - amount_usd;
|
||||
let refundable_after = refundable_before + amount_usd;
|
||||
if !payment_order_refund_amounts_are_consistent(
|
||||
order.amount_usd,
|
||||
refunded_before,
|
||||
refundable_before,
|
||||
) || !refunded_before.is_finite()
|
||||
|| refunded_before < amount_usd
|
||||
|| !refunded_after.is_finite()
|
||||
|| refunded_after < 0.0
|
||||
|| refundable_after < 0.0
|
||||
|| refundable_after > order.amount_usd
|
||||
{
|
||||
return Ok(AdminWalletMutationOutcome::Invalid(
|
||||
"payment order refund amounts are invalid".to_string(),
|
||||
));
|
||||
}
|
||||
let mut order = order;
|
||||
order.refunded_amount_usd = refunded_after;
|
||||
order.refundable_amount_usd = refundable_after;
|
||||
updated_order = Some(order);
|
||||
}
|
||||
|
||||
let mut updated_wallet = wallet.clone();
|
||||
updated_wallet.balance = after_recharge;
|
||||
updated_wallet.total_refunded = (updated_wallet.total_refunded - amount_usd).max(0.0);
|
||||
updated_wallet.total_refunded = after_total_refunded;
|
||||
updated_wallet.updated_at_unix_secs = now_unix_secs;
|
||||
|
||||
let transaction = AdminWalletTransactionRecord {
|
||||
@@ -310,7 +479,7 @@ impl AppState {
|
||||
reason_code: "refund_revert".to_string(),
|
||||
amount: amount_usd,
|
||||
balance_before: before_total,
|
||||
balance_after: after_recharge + before_gift,
|
||||
balance_after: after_total,
|
||||
recharge_balance_before: before_recharge,
|
||||
recharge_balance_after: after_recharge,
|
||||
gift_balance_before: before_gift,
|
||||
@@ -322,23 +491,13 @@ impl AppState {
|
||||
created_at_unix_ms: now_unix_secs,
|
||||
};
|
||||
|
||||
if let Some(payment_order_id) = refund.payment_order_id.clone() {
|
||||
let Some(order_store) = self.admin_wallet_payment_order_store.as_ref() else {
|
||||
return Ok(AdminWalletMutationOutcome::Unavailable);
|
||||
};
|
||||
let maybe_order = order_store
|
||||
if let Some(updated_order) = updated_order {
|
||||
self.admin_wallet_payment_order_store
|
||||
.as_ref()
|
||||
.expect("admin wallet payment order store should exist")
|
||||
.lock()
|
||||
.expect("admin wallet payment order store should lock")
|
||||
.get(&payment_order_id)
|
||||
.cloned();
|
||||
if let Some(mut order) = maybe_order {
|
||||
order.refunded_amount_usd -= amount_usd;
|
||||
order.refundable_amount_usd += amount_usd;
|
||||
order_store
|
||||
.lock()
|
||||
.expect("admin wallet payment order store should lock")
|
||||
.insert(order.id.clone(), order);
|
||||
}
|
||||
.insert(updated_order.id.clone(), updated_order);
|
||||
}
|
||||
|
||||
let mut updated_refund = refund;
|
||||
@@ -354,6 +513,12 @@ impl AppState {
|
||||
.lock()
|
||||
.expect("admin wallet refund store should lock")
|
||||
.insert(updated_refund.id.clone(), updated_refund.clone());
|
||||
if let Some(transaction_store) = self.admin_wallet_transaction_store.as_ref() {
|
||||
transaction_store
|
||||
.lock()
|
||||
.expect("admin wallet transaction store should lock")
|
||||
.insert(transaction.id.clone(), transaction.clone());
|
||||
}
|
||||
|
||||
self.invalidate_auth_context_cache();
|
||||
return Ok(AdminWalletMutationOutcome::Applied((
|
||||
@@ -446,3 +611,103 @@ fn stored_admin_wallet_transaction_to_gateway(
|
||||
created_at_unix_ms: transaction.created_at_unix_ms.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn refund_with_proof(proof: serde_json::Value) -> AdminWalletRefundRecord {
|
||||
AdminWalletRefundRecord {
|
||||
id: "refund-proof-lifecycle".to_string(),
|
||||
refund_no: "rf-proof-lifecycle".to_string(),
|
||||
wallet_id: "wallet-proof-lifecycle".to_string(),
|
||||
user_id: Some("user-proof-lifecycle".to_string()),
|
||||
payment_order_id: None,
|
||||
source_type: "manual".to_string(),
|
||||
source_id: None,
|
||||
refund_mode: "original_channel".to_string(),
|
||||
amount_usd: 4.0,
|
||||
status: "processing".to_string(),
|
||||
reason: Some("proof lifecycle regression".to_string()),
|
||||
failure_reason: None,
|
||||
gateway_refund_id: Some("gateway-proof-lifecycle".to_string()),
|
||||
payout_method: Some("wxpay".to_string()),
|
||||
payout_reference: None,
|
||||
payout_proof: Some(proof),
|
||||
requested_by: Some("user-proof-lifecycle".to_string()),
|
||||
approved_by: Some("admin-proof-lifecycle".to_string()),
|
||||
processed_by: Some("admin-proof-lifecycle".to_string()),
|
||||
created_at_unix_ms: 1_710_000_000,
|
||||
updated_at_unix_secs: 1_710_000_000,
|
||||
processed_at_unix_secs: Some(1_710_000_010),
|
||||
completed_at_unix_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_preserves_processing_proof_on_non_terminal_retry() {
|
||||
let processing_proof = json!({
|
||||
"gateway": "wxpay",
|
||||
"id": "gateway-proof-lifecycle",
|
||||
"status": "processing"
|
||||
});
|
||||
let state = AppState::new()
|
||||
.expect("gateway state should build")
|
||||
.with_admin_wallet_refunds_for_tests([refund_with_proof(processing_proof.clone())]);
|
||||
|
||||
let outcome = state
|
||||
.admin_complete_wallet_refund(
|
||||
"wallet-proof-lifecycle",
|
||||
"refund-proof-lifecycle",
|
||||
Some("gateway-proof-lifecycle"),
|
||||
None,
|
||||
Some(json!({
|
||||
"gateway": "wxpay",
|
||||
"id": "gateway-proof-lifecycle",
|
||||
"status": "pending",
|
||||
"attempt": 2
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("completion should resolve");
|
||||
let AdminWalletMutationOutcome::Applied(refund) = outcome else {
|
||||
panic!("completion should apply");
|
||||
};
|
||||
assert_eq!(refund.status, "succeeded");
|
||||
assert_eq!(refund.payout_proof, Some(processing_proof));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_allows_terminal_success_proof_to_upgrade_processing_evidence() {
|
||||
let state = AppState::new()
|
||||
.expect("gateway state should build")
|
||||
.with_admin_wallet_refunds_for_tests([refund_with_proof(json!({
|
||||
"gateway": "wxpay",
|
||||
"id": "gateway-proof-lifecycle",
|
||||
"status": "processing"
|
||||
}))]);
|
||||
let success_proof = json!({
|
||||
"gateway": "wxpay",
|
||||
"id": "gateway-proof-lifecycle",
|
||||
"status": "succeeded",
|
||||
"processed_at": "2026-08-29T00:00:00Z"
|
||||
});
|
||||
|
||||
let outcome = state
|
||||
.admin_complete_wallet_refund(
|
||||
"wallet-proof-lifecycle",
|
||||
"refund-proof-lifecycle",
|
||||
Some("gateway-proof-lifecycle"),
|
||||
None,
|
||||
Some(success_proof.clone()),
|
||||
)
|
||||
.await
|
||||
.expect("completion should resolve");
|
||||
let AdminWalletMutationOutcome::Applied(refund) = outcome else {
|
||||
panic!("completion should apply");
|
||||
};
|
||||
assert_eq!(refund.status, "succeeded");
|
||||
assert_eq!(refund.payout_proof, Some(success_proof));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user