Тесты для ключевой бизнес-логики + 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:
@@ -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,
|
||||
"повторный логин не должен задваивать реферальную связь"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user