Тесты для ключевой бизнес-логики + 3 найденных бага
Добавляет 52 теста (было 7), приоритет: биллинг -> auth -> RBAC-гварды -> staff/выводы средств -> hack-сессии -> рефералы -> proxy_internal. Попутно найдены и исправлены реальные баги: - migrations/0004: ALTER TABLE auth_sessions падал на чистой БД, потому что 0002 эту таблицу уже дропнул — ломало --migrate на CI/новом инстансе и весь sqlx::test. Заменено на CREATE TABLE IF NOT EXISTS. - request_withdrawal: гонка при параллельных заявках на вывод (не было FOR UPDATE) — партнёр мог одновременно вывести баланс кратно больше доступного. Новый repository::request_partner_withdrawal блокирует строку партнёра и пересчитывает остаток в одной транзакции. - get_referral_stats: SUM() над BIGINT возвращал NUMERIC, sqlx падал на ColumnDecode для любого юзера с рефералами. - reward_referrer: начислял рефереру 10% при каждой повторной покупке того же приведённого юзера вместо разового бонуса — добавлен фильтр WHERE status = 'pending'. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -178,7 +178,9 @@ async fn create_telegram_session_api(
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let auth_code = state.auth_service.create_telegram_login_code().await?;
|
||||
let deep_link = format!("https://t.me/{}?start={}", state.bot_username, auth_code);
|
||||
Ok(Json(json!({ "auth_code": auth_code, "deep_link": deep_link })))
|
||||
Ok(Json(
|
||||
json!({ "auth_code": auth_code, "deep_link": deep_link }),
|
||||
))
|
||||
}
|
||||
|
||||
#[instrument(skip(state, headers))]
|
||||
@@ -193,7 +195,10 @@ async fn get_telegram_session_api(
|
||||
.await?;
|
||||
|
||||
let Some(session) = session else {
|
||||
return Ok(with_headers(HeaderMap::new(), Json(json!({ "status": "pending" }))));
|
||||
return Ok(with_headers(
|
||||
HeaderMap::new(),
|
||||
Json(json!({ "status": "pending" })),
|
||||
));
|
||||
};
|
||||
|
||||
let mut out_headers = HeaderMap::new();
|
||||
|
||||
@@ -170,3 +170,194 @@ pub async fn partner_guard(
|
||||
Err(deny(user))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::repository::{AppRepository, PgRepository};
|
||||
use crate::modules::auth::service::AuthService;
|
||||
use crate::modules::billing::service::BillingService;
|
||||
use crate::modules::nodes::service::NodeService;
|
||||
use crate::modules::referrals::service::ReferralService;
|
||||
use crate::modules::staff::service::StaffService;
|
||||
use crate::modules::users::service::UserService;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request as HttpRequest, StatusCode},
|
||||
middleware,
|
||||
routing::get,
|
||||
Extension, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn build_state(pool: PgPool) -> ApiState {
|
||||
// BillingService::new паникует без этой переменной окружения; для
|
||||
// тестов ролевых гвардов её конкретное значение не имеет значения.
|
||||
std::env::set_var("TON_MASTER_WALLET", "UQtest_wallet_for_guard_tests");
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
ApiState {
|
||||
repo: repo.clone(),
|
||||
auth_service: AuthService::new(repo.clone()),
|
||||
billing_service: BillingService::new(repo.clone(), None),
|
||||
node_service: NodeService::new(repo.clone()),
|
||||
referral_service: ReferralService::new(repo.clone()),
|
||||
user_service: UserService::new(repo.clone()),
|
||||
staff_service: StaffService::new(repo.clone()),
|
||||
jwt_secret: "test-secret".into(),
|
||||
bot_token: "test-bot-token".into(),
|
||||
bot_username: "test_bot".into(),
|
||||
cookie_domain: "".into(),
|
||||
cookie_secure: false,
|
||||
csrf_allowed_origins: vec![],
|
||||
proxy_internal_secret: "test-internal-secret".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_user(role: &str, can_manage_nodes: bool) -> User {
|
||||
User {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
tg_id: None,
|
||||
tg_username: None,
|
||||
role: role.into(),
|
||||
balance: 0,
|
||||
game_tickets: 0,
|
||||
has_used_trial: false,
|
||||
seed_hash: None,
|
||||
last_hack_at: None,
|
||||
data_limit_bytes: None,
|
||||
data_used_bytes: 0,
|
||||
data_period_reset_at: None,
|
||||
username: None,
|
||||
password_hash: None,
|
||||
can_manage_nodes,
|
||||
commission_percent: None,
|
||||
partner_code: None,
|
||||
referred_by_partner_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Строит одноразовый роутер с единственным guard'ом под тестом, инжектит
|
||||
/// заданного пользователя напрямую в extensions (минуя настоящий auth_guard/JWT
|
||||
/// — гварды читают только Extension<User>, см. `current_user`) и возвращает
|
||||
/// итоговый HTTP-статус.
|
||||
async fn status_for<G, Fut>(state: ApiState, user: User, guard: G) -> StatusCode
|
||||
where
|
||||
G: Fn(State<ApiState>, Request, Next) -> Fut + Clone + Send + Sync + 'static,
|
||||
Fut: std::future::Future<Output = Result<Response, AppError>> + Send + 'static,
|
||||
{
|
||||
let app = Router::new()
|
||||
.route("/", get(|| async { StatusCode::OK }))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), guard))
|
||||
.layer(Extension(user))
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(HttpRequest::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
resp.status()
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_owner_guard_matrix(pool: PgPool) {
|
||||
let state = build_state(pool);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("owner", false), owner_guard).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("moderator", true), owner_guard).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("partner", false), owner_guard).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("user", false), owner_guard).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_staff_guard_matrix(pool: PgPool) {
|
||||
let state = build_state(pool);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("owner", false), staff_guard).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("moderator", false), staff_guard).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("partner", false), staff_guard).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("user", false), staff_guard).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_node_manage_guard_matrix(pool: PgPool) {
|
||||
let state = build_state(pool);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("owner", false), node_manage_guard).await,
|
||||
StatusCode::OK,
|
||||
"owner управляет нодами всегда, независимо от can_manage_nodes"
|
||||
);
|
||||
assert_eq!(
|
||||
status_for(
|
||||
state.clone(),
|
||||
make_user("moderator", true),
|
||||
node_manage_guard
|
||||
)
|
||||
.await,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
status_for(
|
||||
state.clone(),
|
||||
make_user("moderator", false),
|
||||
node_manage_guard
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"модератор без выданного права can_manage_nodes не должен проходить"
|
||||
);
|
||||
assert_eq!(
|
||||
status_for(
|
||||
state.clone(),
|
||||
make_user("partner", false),
|
||||
node_manage_guard
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_partner_guard_matrix(pool: PgPool) {
|
||||
let state = build_state(pool);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("partner", false), partner_guard).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("owner", false), partner_guard).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("moderator", true), partner_guard).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
status_for(state.clone(), make_user("user", false), partner_guard).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ pub struct Claims {
|
||||
/// Пара токенов, выдаваемая при входе: короткий access-JWT (кладётся в
|
||||
/// `Authorization`/cookie на каждый запрос) и непрозрачный refresh-токен
|
||||
/// (только в отдельной cookie, только на `/auth/refresh`).
|
||||
#[derive(Debug)]
|
||||
pub struct Session {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
@@ -495,3 +496,304 @@ impl AuthService {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::repository::PgRepository;
|
||||
use sqlx::PgPool;
|
||||
|
||||
const SECRET: &str = "test-jwt-secret";
|
||||
const SALT: &str = "test-seed-salt-min-16-chars";
|
||||
|
||||
// --- Ротация refresh-токена и обнаружение переиспользования ---
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_refresh_session_rotates_and_revokes_old_token(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = AuthService::new(repo.clone());
|
||||
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
let session = svc
|
||||
.issue_session(user.id, Some(1), SECRET, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rotated = svc
|
||||
.refresh_session(&session.refresh_token, SECRET, None)
|
||||
.await
|
||||
.expect("первая ротация должна пройти");
|
||||
assert_ne!(rotated.refresh_token, session.refresh_token);
|
||||
|
||||
// Старый refresh-токен теперь отозван — повторное предъявление отклоняется.
|
||||
let err = svc
|
||||
.refresh_session(&session.refresh_token, SECRET, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_refresh_reuse_after_rotation_revokes_all_sessions(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = AuthService::new(repo.clone());
|
||||
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
let session = svc
|
||||
.issue_session(user.id, Some(1), SECRET, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let rotated = svc
|
||||
.refresh_session(&session.refresh_token, SECRET, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Переиспользование уже отозванного (старого) токена — сигнал компрометации.
|
||||
let err = svc
|
||||
.refresh_session(&session.refresh_token, SECRET, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||
|
||||
// Даже свежая пара, выданная легитимной ротацией ДО обнаружения реюза,
|
||||
// должна была быть отозвана целиком (revoke_all_refresh_sessions).
|
||||
let err2 = svc
|
||||
.refresh_session(&rotated.refresh_token, SECRET, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err2, AppError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_refresh_session_expired_rejected(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = AuthService::new(repo.clone());
|
||||
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
let raw_token = "manually-crafted-refresh-token";
|
||||
let token_hash = AuthService::hash_refresh_token(raw_token);
|
||||
repo.create_refresh_session(
|
||||
user.id,
|
||||
&token_hash,
|
||||
None,
|
||||
Utc::now() - chrono::Duration::days(1),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = svc
|
||||
.refresh_session(raw_token, SECRET, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
// --- login_staff: без user enumeration ---
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_login_staff_success(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = AuthService::new(repo.clone());
|
||||
|
||||
let hash = AuthService::hash_password("correct horse battery staple").unwrap();
|
||||
repo.create_staff_user("owner1", &hash, "owner", true, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
svc.login_staff("owner1", "correct horse battery staple", SECRET, None)
|
||||
.await
|
||||
.expect("верный логин/пароль должны пройти");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_login_staff_generic_error_no_enumeration(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = AuthService::new(repo.clone());
|
||||
|
||||
let hash = AuthService::hash_password("correct horse battery staple").unwrap();
|
||||
repo.create_staff_user("owner1", &hash, "owner", true, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
// Обычный Telegram-юзер без username/password_hash вообще.
|
||||
repo.upsert_user(42, None).await.unwrap();
|
||||
|
||||
let err_wrong_pass = svc
|
||||
.login_staff("owner1", "wrong password", SECRET, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let err_unknown_user = svc
|
||||
.login_staff("nobody", "whatever", SECRET, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let msg = |e: AppError| match e {
|
||||
AppError::Unauthorized(m) => m,
|
||||
other => panic!("ожидался Unauthorized, получено {other:?}"),
|
||||
};
|
||||
// Обе ошибки должны быть текстуально неотличимы — иначе можно перебором
|
||||
// узнать, существует ли username.
|
||||
assert_eq!(msg(err_wrong_pass), msg(err_unknown_user));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_password_roundtrip() {
|
||||
use argon2::password_hash::{PasswordHash, PasswordVerifier};
|
||||
let hash = AuthService::hash_password("hunter2").unwrap();
|
||||
let parsed = PasswordHash::new(&hash).unwrap();
|
||||
assert!(Argon2::default()
|
||||
.verify_password(b"hunter2", &parsed)
|
||||
.is_ok());
|
||||
assert!(Argon2::default()
|
||||
.verify_password(b"wrong", &parsed)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
// --- Ghost Protocol: seed-вход ---
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_register_and_login_seed_roundtrip(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = AuthService::new(repo.clone());
|
||||
|
||||
let (seed, _session) = svc.register_seed(SALT, SECRET, None).await.unwrap();
|
||||
svc.login_seed(&seed, SALT, SECRET, None)
|
||||
.await
|
||||
.expect("вход по только что сгенерированному seed должен пройти");
|
||||
|
||||
let err = svc
|
||||
.login_seed("totally-wrong-seed-value", SALT, SECRET, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
// --- Telegram Login Widget: HMAC-подпись ---
|
||||
|
||||
fn compute_valid_tg_hash(
|
||||
bot_token: &str,
|
||||
id: i64,
|
||||
username: Option<&str>,
|
||||
auth_date: i64,
|
||||
) -> String {
|
||||
let mut data_check_arr = vec![format!("auth_date={}", auth_date), format!("id={}", id)];
|
||||
if let Some(v) = username {
|
||||
data_check_arr.push(format!("username={}", v));
|
||||
}
|
||||
data_check_arr.sort();
|
||||
let data_check_string = data_check_arr.join("\n");
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bot_token.trim().as_bytes());
|
||||
let secret_key = hasher.finalize();
|
||||
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&secret_key).unwrap();
|
||||
mac.update(data_check_string.as_bytes());
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_verify_tg_widget_auth_accepts_valid_signature(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = AuthService::new(repo);
|
||||
let bot_token = "123:ABC-fake-bot-token";
|
||||
let auth_date = Utc::now().timestamp();
|
||||
let hash = compute_valid_tg_hash(bot_token, 555, Some("neo"), auth_date);
|
||||
|
||||
svc.verify_tg_widget_auth(
|
||||
bot_token,
|
||||
555,
|
||||
None,
|
||||
None,
|
||||
Some("neo"),
|
||||
None,
|
||||
auth_date,
|
||||
&hash,
|
||||
)
|
||||
.expect("валидная подпись должна приниматься");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_verify_tg_widget_auth_rejects_tampered_signature(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = AuthService::new(repo);
|
||||
let bot_token = "123:ABC-fake-bot-token";
|
||||
let auth_date = Utc::now().timestamp();
|
||||
let hash = compute_valid_tg_hash(bot_token, 555, Some("neo"), auth_date);
|
||||
|
||||
// Тот же хэш, но id подделан относительно того, что реально подписано.
|
||||
let err = svc
|
||||
.verify_tg_widget_auth(
|
||||
bot_token,
|
||||
666,
|
||||
None,
|
||||
None,
|
||||
Some("neo"),
|
||||
None,
|
||||
auth_date,
|
||||
&hash,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_verify_tg_widget_auth_rejects_expired_session(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = AuthService::new(repo);
|
||||
let bot_token = "123:ABC-fake-bot-token";
|
||||
let auth_date = Utc::now().timestamp() - 90_000; // > 86400 секунд назад
|
||||
let hash = compute_valid_tg_hash(bot_token, 555, Some("neo"), auth_date);
|
||||
|
||||
let err = svc
|
||||
.verify_tg_widget_auth(
|
||||
bot_token,
|
||||
555,
|
||||
None,
|
||||
None,
|
||||
Some("neo"),
|
||||
None,
|
||||
auth_date,
|
||||
&hash,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
// --- process_login: привязка реферала только для новых юзеров, без self-ref ---
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_process_login_applies_referral_for_new_user_only(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = AuthService::new(repo.clone());
|
||||
|
||||
let referrer = repo.upsert_user(100, None).await.unwrap();
|
||||
|
||||
// Новый юзер с валидным реферальным кодом (tg_id реферера) -> связь создаётся.
|
||||
svc.process_login(200, None, Some("100".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
let (count, _) = repo.get_referral_stats(referrer.id).await.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
// Самореферал игнорируется.
|
||||
svc.process_login(300, None, Some("300".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
let (self_ref_count, _) = repo
|
||||
.get_referral_stats(repo.get_user_by_tg_id(300).await.unwrap().unwrap().id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(self_ref_count, 0);
|
||||
|
||||
// Повторный логин уже существующего юзера с ref_code — не создаёт новую связь
|
||||
// (is_new уже false на момент проверки).
|
||||
svc.process_login(200, None, Some("100".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
let (count_after, _) = repo.get_referral_stats(referrer.id).await.unwrap();
|
||||
assert_eq!(
|
||||
count_after, 1,
|
||||
"повторный логин не должен задваивать реферальную связь"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,9 @@ impl CryptoBotProvider {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"Crypto-Pay-API-Token",
|
||||
HeaderValue::from_str(&self.api_token)
|
||||
.map_err(|e| AppError::Internal(format!("Некорректный CRYPTOBOT_API_TOKEN: {e}")))?,
|
||||
HeaderValue::from_str(&self.api_token).map_err(|e| {
|
||||
AppError::Internal(format!("Некорректный CRYPTOBOT_API_TOKEN: {e}"))
|
||||
})?,
|
||||
);
|
||||
reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
|
||||
+326
-11
@@ -188,9 +188,7 @@ impl BillingService {
|
||||
})?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::Internal(format!("Ошибка парсинга курса TON: {}", e))
|
||||
})?;
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
|
||||
|
||||
let rate_f64 = rate_res["rates"]["TON"]["prices"][¤cy_upper]
|
||||
.as_f64()
|
||||
@@ -256,12 +254,15 @@ impl BillingService {
|
||||
// 2. Получаем провайдера, которым был выставлен именно этот инвойс —
|
||||
// раньше здесь был захардкожен "TON", что ломало подтверждение любого
|
||||
// другого провайдера (см. `invoice.provider`, заполняется в `create_invoice`).
|
||||
let provider = self.providers.get(invoice.provider.as_str()).ok_or_else(|| {
|
||||
AppError::Internal(format!(
|
||||
"Провайдер {} не сконфигурирован на этом инстансе",
|
||||
invoice.provider
|
||||
))
|
||||
})?;
|
||||
let provider = self
|
||||
.providers
|
||||
.get(invoice.provider.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::Internal(format!(
|
||||
"Провайдер {} не сконфигурирован на этом инстансе",
|
||||
invoice.provider
|
||||
))
|
||||
})?;
|
||||
|
||||
// 3. Провайдер лезет в блокчейн и проверяет транзакцию
|
||||
let is_valid = provider
|
||||
@@ -332,8 +333,10 @@ impl BillingService {
|
||||
let Some(percent) = partner.commission_percent else {
|
||||
return Ok(()); // партнёр без выставленного процента — молча пропускаем
|
||||
};
|
||||
let Some(fiat_amount_minimal) =
|
||||
self.repo.get_plan_price(&invoice.plan_name, &invoice.currency).await?
|
||||
let Some(fiat_amount_minimal) = self
|
||||
.repo
|
||||
.get_plan_price(&invoice.plan_name, &invoice.currency)
|
||||
.await?
|
||||
else {
|
||||
return Ok(()); // тариф/валюта более не активны — пропускаем
|
||||
};
|
||||
@@ -481,3 +484,315 @@ impl BillingService {
|
||||
Ok(nanotons)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::repository::PgRepository;
|
||||
use crate::modules::billing::providers::{InvoiceData, PaymentProvider};
|
||||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
use sqlx::PgPool;
|
||||
|
||||
// --- calculate_ton_invoice: чистая арифметика, без БД ---
|
||||
|
||||
#[test]
|
||||
fn test_calculate_ton_invoice_applies_2_percent_buffer() {
|
||||
// $10.00 (1000 центов) по курсу 2 USD/TON -> 5 TON чистых -> +2% буфер -> 5.1 TON
|
||||
let rate = Decimal::new(2, 0);
|
||||
let nanotons = BillingService::calculate_ton_invoice(1000, rate).unwrap();
|
||||
assert_eq!(nanotons, 5_100_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_ton_invoice_zero_rate_errors() {
|
||||
let err = BillingService::calculate_ton_invoice(1000, Decimal::ZERO).unwrap_err();
|
||||
assert!(err.contains("не может быть нулевым"));
|
||||
}
|
||||
|
||||
// --- confirm_payment: владение, идемпотентность, маршрутизация провайдера ---
|
||||
|
||||
struct FakeProvider {
|
||||
id: &'static str,
|
||||
verify_result: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PaymentProvider for FakeProvider {
|
||||
fn provider_id(&self) -> &'static str {
|
||||
self.id
|
||||
}
|
||||
async fn create_checkout_session(
|
||||
&self,
|
||||
_invoice: &InvoiceData,
|
||||
) -> Result<serde_json::Value, AppError> {
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
async fn verify_payment(
|
||||
&self,
|
||||
_external_tx_id: &str,
|
||||
_expected_amount: i64,
|
||||
_expected_invoice_id: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
Ok(self.verify_result)
|
||||
}
|
||||
}
|
||||
|
||||
fn service_with_fake_provider(
|
||||
repo: Arc<dyn AppRepository>,
|
||||
verify_result: bool,
|
||||
) -> BillingService {
|
||||
let mut providers: HashMap<&'static str, Box<dyn PaymentProvider>> = HashMap::new();
|
||||
providers.insert(
|
||||
"TEST",
|
||||
Box::new(FakeProvider {
|
||||
id: "TEST",
|
||||
verify_result,
|
||||
}),
|
||||
);
|
||||
BillingService {
|
||||
repo,
|
||||
providers,
|
||||
redis: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_confirm_payment_rejects_non_owner(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = service_with_fake_provider(repo.clone(), true);
|
||||
|
||||
let owner = repo.upsert_user(1, None).await.unwrap();
|
||||
let stranger = repo.upsert_user(2, None).await.unwrap();
|
||||
let invoice_id = repo
|
||||
.create_invoice(owner.id, "GHOST_1M", "TEST", "USD", 500)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = svc
|
||||
.confirm_payment(stranger.id, invoice_id, "tx1")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||
|
||||
// Инвойс не должен был измениться.
|
||||
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
|
||||
assert_eq!(invoice.status, "pending");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_confirm_payment_idempotent_when_already_paid(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
// verify_result=false: если бы идемпотентность не сработала, второй вызов
|
||||
// упал бы на верификации — но код обязан выйти раньше, на шаге "already paid".
|
||||
let svc = service_with_fake_provider(repo.clone(), false);
|
||||
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
let invoice_id = repo
|
||||
.create_invoice(user.id, "GHOST_1M", "TEST", "USD", 500)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.update_invoice_status(invoice_id, "paid", Some("tx0"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
svc.confirm_payment(user.id, invoice_id, "tx1")
|
||||
.await
|
||||
.expect("повторное подтверждение уже оплаченного инвойса должно быть no-op");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_confirm_payment_unknown_provider_errors(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = service_with_fake_provider(repo.clone(), true);
|
||||
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
// Инвойс выставлен провайдером, которого нет в HashMap этого инстанса —
|
||||
// регрессионный тест на баг "confirm_payment жёстко ходил в TON независимо
|
||||
// от invoice.provider".
|
||||
let invoice_id = repo
|
||||
.create_invoice(user.id, "GHOST_1M", "NOT_CONFIGURED", "USD", 500)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = svc
|
||||
.confirm_payment(user.id, invoice_id, "tx1")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_confirm_payment_failed_verification_marks_invoice_failed(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = service_with_fake_provider(repo.clone(), false);
|
||||
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
let invoice_id = repo
|
||||
.create_invoice(user.id, "GHOST_1M", "TEST", "USD", 500)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = svc
|
||||
.confirm_payment(user.id, invoice_id, "tx1")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||
|
||||
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
|
||||
assert_eq!(invoice.status, "failed");
|
||||
|
||||
// Подписка не должна была активироваться.
|
||||
assert!(repo.get_subscription(user.id).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_confirm_payment_success_activates_subscription_and_rewards_referrer(
|
||||
pool: PgPool,
|
||||
) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = service_with_fake_provider(repo.clone(), true);
|
||||
|
||||
let referrer = repo.upsert_user(1, None).await.unwrap();
|
||||
let buyer = repo.upsert_user(2, None).await.unwrap();
|
||||
repo.create_referral(referrer.id, buyer.id).await.unwrap();
|
||||
|
||||
let invoice_id = repo
|
||||
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500) // $5.00
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||||
.await
|
||||
.expect("успешная верификация должна пройти");
|
||||
|
||||
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
|
||||
assert_eq!(invoice.status, "paid");
|
||||
|
||||
let sub = repo
|
||||
.get_subscription(buyer.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("подписка должна активироваться");
|
||||
assert_eq!(sub.plan_name, "GHOST_1M");
|
||||
assert_eq!(sub.status, "active");
|
||||
|
||||
// 10% от суммы инвойса (500) = 50, зачисляется рефереру на баланс.
|
||||
let referrer_updated = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
|
||||
assert_eq!(referrer_updated.balance, 50);
|
||||
}
|
||||
|
||||
// --- Партнёрская комиссия: считается от get_plan_price, не от invoice.amount ---
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_partner_commission_uses_plan_price_not_invoice_amount(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = service_with_fake_provider(repo.clone(), true);
|
||||
|
||||
let partner = repo
|
||||
.create_staff_user(
|
||||
"partner1",
|
||||
"hash",
|
||||
"partner",
|
||||
false,
|
||||
Some(Decimal::new(10, 0)), // 10%
|
||||
Some("p_abc123"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let buyer = repo.upsert_user(2, None).await.unwrap();
|
||||
repo.set_referred_by_partner(buyer.id, partner.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// invoice.amount = 999999 — намеренно НЕ совпадает с реальной ценой тарифа
|
||||
// (симулирует TON-инвойс, где amount — nanoTON, а не фиат), чтобы доказать,
|
||||
// что комиссия считается через get_plan_price("GHOST_1M", "USD") = 500,
|
||||
// а не от этого числа.
|
||||
let invoice_id = repo
|
||||
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 999_999)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
|
||||
let usd_earnings = earnings.iter().find(|e| e.currency == "USD").unwrap();
|
||||
// 10% от 500 (реальная цена GHOST_1M/USD) = 50, а НЕ 10% от 999999.
|
||||
assert_eq!(usd_earnings.total_earned, 50);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_partner_commission_idempotent_on_duplicate_confirm(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = service_with_fake_provider(repo.clone(), true);
|
||||
|
||||
let partner = repo
|
||||
.create_staff_user(
|
||||
"partner1",
|
||||
"hash",
|
||||
"partner",
|
||||
false,
|
||||
Some(Decimal::new(10, 0)),
|
||||
Some("p_abc123"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let buyer = repo.upsert_user(2, None).await.unwrap();
|
||||
repo.set_referred_by_partner(buyer.id, partner.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let invoice_id = repo
|
||||
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||||
.await
|
||||
.unwrap();
|
||||
// Второй вызов — инвойс уже "paid", подтверждение — no-op (см. тест выше),
|
||||
// поэтому record_partner_commission_if_referred не должен вызваться снова.
|
||||
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
|
||||
let usd_earnings = earnings.iter().find(|e| e.currency == "USD").unwrap();
|
||||
assert_eq!(
|
||||
usd_earnings.total_earned, 50,
|
||||
"комиссия не должна начислиться дважды"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_partner_commission_skipped_without_percent(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = service_with_fake_provider(repo.clone(), true);
|
||||
|
||||
// Партнёр без выставленного процента — комиссия не должна начисляться,
|
||||
// и confirm_payment не должен упасть.
|
||||
let partner = repo
|
||||
.create_staff_user("partner1", "hash", "partner", false, None, Some("p_abc123"))
|
||||
.await
|
||||
.unwrap();
|
||||
let buyer = repo.upsert_user(2, None).await.unwrap();
|
||||
repo.set_referred_by_partner(buyer.id, partner.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let invoice_id = repo
|
||||
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
|
||||
assert!(earnings.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,10 @@ pub fn admin_router(state: ApiState) -> Router<ApiState> {
|
||||
.route("/", get(list_all_nodes).post(add_node))
|
||||
.route("/{id}", delete(remove_node))
|
||||
.route("/{id}/restart", post(restart_node))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), node_manage_guard))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
node_manage_guard,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth_guard))
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ struct ValidatePayload {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Debug)]
|
||||
struct ValidateResponse {
|
||||
user_id: Uuid,
|
||||
limit_bytes: Option<i64>,
|
||||
@@ -84,7 +84,7 @@ struct UsagePayload {
|
||||
delta_bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Debug)]
|
||||
struct UsageResponse {
|
||||
used_bytes: i64,
|
||||
limit_bytes: Option<i64>,
|
||||
@@ -112,3 +112,157 @@ async fn usage_api(
|
||||
over_limit,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::repository::{AppRepository, PgRepository};
|
||||
use crate::modules::auth::service::{AuthService, Claims};
|
||||
use crate::modules::billing::service::BillingService;
|
||||
use crate::modules::nodes::service::NodeService;
|
||||
use crate::modules::referrals::service::ReferralService;
|
||||
use crate::modules::staff::service::StaffService;
|
||||
use crate::modules::users::service::UserService;
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
const SECRET: &str = "test-jwt-secret";
|
||||
|
||||
fn build_state(pool: PgPool) -> ApiState {
|
||||
std::env::set_var(
|
||||
"TON_MASTER_WALLET",
|
||||
"UQtest_wallet_for_proxy_internal_tests",
|
||||
);
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
ApiState {
|
||||
repo: repo.clone(),
|
||||
auth_service: AuthService::new(repo.clone()),
|
||||
billing_service: BillingService::new(repo.clone(), None),
|
||||
node_service: NodeService::new(repo.clone()),
|
||||
referral_service: ReferralService::new(repo.clone()),
|
||||
user_service: UserService::new(repo.clone()),
|
||||
staff_service: StaffService::new(repo.clone()),
|
||||
jwt_secret: SECRET.into(),
|
||||
bot_token: "test-bot-token".into(),
|
||||
bot_username: "test_bot".into(),
|
||||
cookie_domain: "".into(),
|
||||
cookie_secure: false,
|
||||
csrf_allowed_origins: vec![],
|
||||
proxy_internal_secret: "test-internal-secret".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_token(user_id: uuid::Uuid) -> String {
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
tg_id: None,
|
||||
exp: (chrono::Utc::now() + chrono::Duration::minutes(15)).timestamp() as usize,
|
||||
};
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(SECRET.as_bytes()),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_validate_api_rejects_garbage_token(pool: PgPool) {
|
||||
let state = build_state(pool);
|
||||
let err = validate_api(
|
||||
State(state),
|
||||
Json(ValidatePayload {
|
||||
token: "not-a-real-jwt".into(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_validate_api_accepts_valid_token_and_returns_limits(pool: PgPool) {
|
||||
let state = build_state(pool.clone());
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
repo.set_data_limit(user.id, Some(1_000_000)).await.unwrap();
|
||||
|
||||
let token = sign_token(user.id);
|
||||
let resp = validate_api(State(state), Json(ValidatePayload { token }))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.user_id, user.id);
|
||||
assert_eq!(resp.limit_bytes, Some(1_000_000));
|
||||
assert_eq!(resp.used_bytes, 0);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_usage_api_unknown_user_not_found(pool: PgPool) {
|
||||
let state = build_state(pool);
|
||||
let err = usage_api(
|
||||
State(state),
|
||||
Json(UsagePayload {
|
||||
user_id: uuid::Uuid::new_v4(),
|
||||
delta_bytes: 100,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::UserNotFound));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_usage_api_over_limit_boundary(pool: PgPool) {
|
||||
let state = build_state(pool.clone());
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
repo.set_data_limit(user.id, Some(1000)).await.unwrap();
|
||||
|
||||
// 999 из 1000 — ещё не превышен лимит.
|
||||
let resp1 = usage_api(
|
||||
State(state.clone()),
|
||||
Json(UsagePayload {
|
||||
user_id: user.id,
|
||||
delta_bytes: 999,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp1.used_bytes, 999);
|
||||
assert!(!resp1.over_limit);
|
||||
|
||||
// Ровно 1000 из 1000 — лимит уже считается превышенным (>=, не >).
|
||||
let resp2 = usage_api(
|
||||
State(state),
|
||||
Json(UsagePayload {
|
||||
user_id: user.id,
|
||||
delta_bytes: 1,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp2.used_bytes, 1000);
|
||||
assert!(resp2.over_limit);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_usage_api_unlimited_user_never_over_limit(pool: PgPool) {
|
||||
let state = build_state(pool.clone());
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
// data_limit_bytes остаётся NULL — безлимит.
|
||||
|
||||
let resp = usage_api(
|
||||
State(state),
|
||||
Json(UsagePayload {
|
||||
user_id: user.id,
|
||||
delta_bytes: 999_999_999,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!resp.over_limit);
|
||||
assert_eq!(resp.limit_bytes, None);
|
||||
}
|
||||
}
|
||||
|
||||
+168
-16
@@ -6,9 +6,9 @@
|
||||
|
||||
use crate::{
|
||||
db::models::{PartnerEarningsByCurrency, User, WithdrawalRequest},
|
||||
db::repository::AppRepository,
|
||||
error::AppError,
|
||||
modules::auth::service::AuthService,
|
||||
db::repository::AppRepository,
|
||||
};
|
||||
use rand::Rng;
|
||||
use std::sync::Arc;
|
||||
@@ -82,7 +82,10 @@ impl StaffService {
|
||||
}
|
||||
|
||||
pub async fn list_staff(&self) -> Result<Vec<User>, AppError> {
|
||||
self.repo.list_staff_users(None).await.map_err(AppError::Database)
|
||||
self.repo
|
||||
.list_staff_users(None)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_all_withdrawals(&self) -> Result<Vec<WithdrawalRequest>, AppError> {
|
||||
@@ -126,6 +129,9 @@ impl StaffService {
|
||||
|
||||
/// Партнёр подаёт заявку на вывод — проверяем, что запрошенная сумма не
|
||||
/// превышает доступный остаток именно в этой валюте (без конвертации).
|
||||
/// Проверка баланса и вставка заявки — одна транзакция с блокировкой
|
||||
/// строки партнёра (`request_partner_withdrawal`), иначе несколько
|
||||
/// параллельных заявок на весь остаток могут пройти одновременно.
|
||||
pub async fn request_withdrawal(
|
||||
&self,
|
||||
partner_id: Uuid,
|
||||
@@ -138,29 +144,175 @@ impl StaffService {
|
||||
"Сумма вывода должна быть положительной.".into(),
|
||||
));
|
||||
}
|
||||
let earnings = self.get_earnings(partner_id).await?;
|
||||
let available = earnings
|
||||
.iter()
|
||||
.find(|e| e.currency == currency)
|
||||
.map(|e| e.available)
|
||||
.unwrap_or(0);
|
||||
if amount > available {
|
||||
return Err(AppError::BusinessLogic(
|
||||
"Запрошенная сумма превышает доступный остаток в этой валюте.".into(),
|
||||
));
|
||||
}
|
||||
self.repo
|
||||
.create_withdrawal_request(partner_id, currency, amount, note)
|
||||
.request_partner_withdrawal(partner_id, currency, amount, note)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
.map_err(|e| match e.as_str() {
|
||||
"INSUFFICIENT_BALANCE" => AppError::BusinessLogic(
|
||||
"Запрошенная сумма превышает доступный остаток в этой валюте.".into(),
|
||||
),
|
||||
_ => AppError::Internal(format!("Ошибка обработки заявки на вывод: {e}")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn map_create_error(e: sqlx::Error, kind: &str) -> AppError {
|
||||
if let sqlx::Error::Database(db_err) = &e {
|
||||
if db_err.constraint() == Some("users_username_key") {
|
||||
return AppError::BusinessLogic(format!("Логин уже занят, выберите другой для {kind}."));
|
||||
return AppError::BusinessLogic(format!(
|
||||
"Логин уже занят, выберите другой для {kind}."
|
||||
));
|
||||
}
|
||||
}
|
||||
AppError::Database(e)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::repository::PgRepository;
|
||||
use rust_decimal::Decimal;
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_create_moderator_duplicate_username_is_friendly_error(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = StaffService::new(repo);
|
||||
|
||||
svc.create_moderator("mod1", "pass1", false).await.unwrap();
|
||||
let err = svc
|
||||
.create_moderator("mod1", "different-pass", true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_create_partner_duplicate_username_is_friendly_error(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = StaffService::new(repo);
|
||||
|
||||
svc.create_partner("p1", "pass1", Decimal::new(10, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let err = svc
|
||||
.create_partner("p1", "different-pass", Decimal::new(20, 0))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||
}
|
||||
|
||||
async fn seed_partner_with_earnings(repo: &Arc<dyn AppRepository>, earned: i64) -> Uuid {
|
||||
let partner = repo
|
||||
.create_staff_user(
|
||||
"partner1",
|
||||
"hash",
|
||||
"partner",
|
||||
false,
|
||||
Some(Decimal::new(10, 0)),
|
||||
Some("p_abc123"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let buyer = repo.upsert_user(999, None).await.unwrap();
|
||||
let invoice_id = repo
|
||||
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.record_partner_commission(
|
||||
partner.id,
|
||||
invoice_id,
|
||||
buyer.id,
|
||||
"USD",
|
||||
earned,
|
||||
Decimal::new(10, 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
partner.id
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_request_withdrawal_rejects_non_positive_amount(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = StaffService::new(repo.clone());
|
||||
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
|
||||
|
||||
let err = svc
|
||||
.request_withdrawal(partner_id, "USD", 0, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||
|
||||
let err2 = svc
|
||||
.request_withdrawal(partner_id, "USD", -50, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err2, AppError::BusinessLogic(_)));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_request_withdrawal_rejects_over_balance(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = StaffService::new(repo.clone());
|
||||
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
|
||||
|
||||
let err = svc
|
||||
.request_withdrawal(partner_id, "USD", 1001, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_request_withdrawal_succeeds_within_balance_and_updates_available(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = StaffService::new(repo.clone());
|
||||
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
|
||||
|
||||
svc.request_withdrawal(partner_id, "USD", 400, None)
|
||||
.await
|
||||
.expect("400 из доступных 1000 должно пройти");
|
||||
|
||||
let earnings = svc.get_earnings(partner_id).await.unwrap();
|
||||
let usd = earnings.iter().find(|e| e.currency == "USD").unwrap();
|
||||
assert_eq!(usd.total_earned, 1000);
|
||||
assert_eq!(usd.pending_withdrawal, 400);
|
||||
assert_eq!(usd.available, 600);
|
||||
}
|
||||
|
||||
/// Регрессионный тест на гонку: `request_partner_withdrawal` обязан
|
||||
/// блокировать строку партнёра (`FOR UPDATE`), иначе несколько параллельных
|
||||
/// заявок на весь остаток проходят одновременно и суммарно превышают
|
||||
/// доступный баланс партнёра (было воспроизведено до фикса — все 10 из 10
|
||||
/// параллельных заявок проходили).
|
||||
#[sqlx::test]
|
||||
async fn test_concurrent_withdrawal_requests_do_not_overdraw_balance(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||
let partner_id = seed_partner_with_earnings(&repo, 1000).await;
|
||||
|
||||
let num_requests = 10;
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..num_requests {
|
||||
let repo_clone: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||
let svc = StaffService::new(repo_clone);
|
||||
handles.push(tokio::spawn(async move {
|
||||
svc.request_withdrawal(partner_id, "USD", 1000, None).await
|
||||
}));
|
||||
}
|
||||
|
||||
let mut success_count = 0;
|
||||
for h in handles {
|
||||
if h.await.unwrap().is_ok() {
|
||||
success_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
success_count, 1,
|
||||
"без блокировки строки несколько параллельных заявок на весь остаток \
|
||||
могли пройти одновременно и увести партнёра в минус — request_withdrawal \
|
||||
нужен FOR UPDATE по аналогии с apply_promo_code"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use uuid::Uuid;
|
||||
|
||||
/// Ответ на `POST /users/hack/start`: доска, которую сервер только что
|
||||
/// сгенерировал и сохранил — клиент обязан играть строго на ней.
|
||||
#[derive(Debug)]
|
||||
pub struct HackSessionStart {
|
||||
pub session_id: Uuid,
|
||||
pub matrix: Vec<Vec<String>>,
|
||||
@@ -123,3 +124,144 @@ impl UserService {
|
||||
Ok(reward)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::repository::PgRepository;
|
||||
use sqlx::PgPool;
|
||||
|
||||
async fn grant_tickets(pool: &PgPool, user_id: Uuid, tickets: i32) {
|
||||
sqlx::query("UPDATE users SET game_tickets = $1 WHERE id = $2")
|
||||
.bind(tickets)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_start_hack_session_no_tickets_errors(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||||
let svc = UserService::new(repo.clone());
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
|
||||
let err = svc.start_hack_session(user.id).await.unwrap_err();
|
||||
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_start_hack_session_too_frequent_errors(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||
let svc = UserService::new(repo.clone());
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
grant_tickets(&pool, user.id, 5).await;
|
||||
|
||||
svc.start_hack_session(user.id)
|
||||
.await
|
||||
.expect("первая игра должна пройти");
|
||||
let err = svc.start_hack_session(user.id).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AppError::BusinessLogic(_)),
|
||||
"повторный старт раньше 45с должен блокироваться rate-limit'ом"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_start_hack_session_decrements_ticket_and_creates_session(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||
let svc = UserService::new(repo.clone());
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
grant_tickets(&pool, user.id, 3).await;
|
||||
|
||||
let session = svc.start_hack_session(user.id).await.unwrap();
|
||||
assert_eq!(session.matrix.len(), cyberhack::GRID_SIZE);
|
||||
assert_eq!(session.targets.len(), 3);
|
||||
|
||||
let updated = repo.get_user_by_id(user.id).await.unwrap().unwrap();
|
||||
assert_eq!(updated.game_tickets, 2);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_submit_hack_session_double_submit_rejected(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||
let svc = UserService::new(repo.clone());
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
grant_tickets(&pool, user.id, 3).await;
|
||||
|
||||
let session = svc.start_hack_session(user.id).await.unwrap();
|
||||
svc.submit_hack_session(user.id, session.session_id, vec![])
|
||||
.await
|
||||
.expect("первая сдача (даже пустым путём) должна пройти");
|
||||
|
||||
let err = svc
|
||||
.submit_hack_session(user.id, session.session_id, vec![])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AppError::BusinessLogic(_)),
|
||||
"повторная сдача уже использованной сессии должна отклоняться"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_submit_hack_session_wrong_user_rejected(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||
let svc = UserService::new(repo.clone());
|
||||
let owner = repo.upsert_user(1, None).await.unwrap();
|
||||
let stranger = repo.upsert_user(2, None).await.unwrap();
|
||||
grant_tickets(&pool, owner.id, 3).await;
|
||||
|
||||
let session = svc.start_hack_session(owner.id).await.unwrap();
|
||||
let err = svc
|
||||
.submit_hack_session(stranger.id, session.session_id, vec![])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_submit_hack_session_rejects_illegal_path_without_awarding_balance(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||
let svc = UserService::new(repo.clone());
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
grant_tickets(&pool, user.id, 3).await;
|
||||
|
||||
let session = svc.start_hack_session(user.id).await.unwrap();
|
||||
// Первый ход обязан быть в ряду 0 (active_index стартует в 0) — (1, 0) нелегален.
|
||||
let illegal_path = vec![(1, 0)];
|
||||
let err = svc
|
||||
.submit_hack_session(user.id, session.session_id, illegal_path)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||||
|
||||
let unchanged = repo.get_user_by_id(user.id).await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
unchanged.balance, 0,
|
||||
"нелегальный путь не должен начислять награду"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_submit_hack_session_end_to_end_credits_returned_reward(pool: PgPool) {
|
||||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
|
||||
let svc = UserService::new(repo.clone());
|
||||
let user = repo.upsert_user(1, None).await.unwrap();
|
||||
grant_tickets(&pool, user.id, 3).await;
|
||||
|
||||
let session = svc.start_hack_session(user.id).await.unwrap();
|
||||
// Первый ход всегда легален в ряду 0, независимо от содержимого доски.
|
||||
let path = vec![(0, 0)];
|
||||
let reward = svc
|
||||
.submit_hack_session(user.id, session.session_id, path)
|
||||
.await
|
||||
.expect("легальный (пусть и не выигрышный) путь не должен отклоняться");
|
||||
|
||||
let updated = repo.get_user_by_id(user.id).await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
updated.balance, reward,
|
||||
"начисленный баланс должен совпадать с возвращённой суммой награды"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user