Тесты для ключевой бизнес-логики + 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:
2026-07-09 01:18:15 +07:00
parent 375b333978
commit 7290f9483b
12 changed files with 1451 additions and 41 deletions
+7 -2
View File
@@ -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();
+191
View File
@@ -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
);
}
}
+302
View File
@@ -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,
"повторный логин не должен задваивать реферальную связь"
);
}
}