Files
netrunner-backend/tests/db_integration_test.rs
T
nineap 52531dd41a Unify auth into refresh-token cookie sessions; server-authoritative nodes and Cyberhack
Auth was effectively broken for every login path: the Telegram handler
built a Set-Cookie header but never attached it to the response, and
Ghost Protocol (seed) login never set a cookie at all — so billing, the
game, and admin actions never actually worked once the frontend was fixed
to stop trying to read an HttpOnly cookie from JS. Replaced the bare
10-minute JWT with AuthService::issue_session: a 15-minute access cookie
plus a rotating 30-day refresh cookie (opaque token, only its SHA-256
hash stored in the new refresh_sessions table); reusing an already-
rotated refresh token now revokes every session for that user. CSRF/CORS
origin allowlists moved from a single hardcoded domain to env-configured
lists so the same session works across the landing and account subdomains.
The bot's WebApp deep links (Личный Кабинет/Тарифы/Синдикат) now bridge
through a short-lived bootstrap token exchanged via POST /auth/exchange
instead of a bare access token in the URL.

Nodes: /nodes/routing now serializes the tunnel_* fields, public_key,
sni_domain and a one-time session token gated on an active subscription
instead of a stub ip/port/protocol list; node provisioning returns 202
immediately and finishes in the background instead of blocking the
request for the whole SSH run; a new background task TCP-pings nodes
every 60s and flips online/offline itself; admin can now force-restart a
node over SSH.

Billing: TON exchange rate is cached in Redis (60s) instead of hitting
TonAPI on every invoice, and the request/response now actually uses the
requested currency and TonAPI's real (uppercase) key casing — previously
only USD/RUB were ever requested and the lookup used the wrong case, so
non-USD/RUB plans could never price correctly.

Cyberhack: the server now generates and stores the board itself and
replays the client's raw click path to compute the score, instead of
clamping a client-reported number — closes the "final score is whatever
the browser sends" hole flagged in the security audit.

Frontend: api.ts no longer tries to read the HttpOnly session cookie from
JS (that never worked) and instead relies on same-origin credentials plus
a silent refresh-and-retry on 401; AdminDashboard's restart/delete actions
are wired up; Auth.tsx drops the artificial delays and requires an
explicit seed download/confirmation before continuing, since a lost Ghost
seed is unrecoverable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:29:51 +07:00

131 lines
4.2 KiB
Rust

use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
use sqlx::{PgPool, Row};
use std::sync::Arc;
// =====================================================================
// ТЕСТ 1: Создание пользователя и пополнение баланса
// =====================================================================
// SQLx сам создаст чистую БД, сам накатит миграции и сам передаст сюда pool
#[sqlx::test]
async fn test_upsert_user_and_add_balance(pool: PgPool) {
let repo = PgRepository::new(pool);
let tg_id = 999111222;
let user = repo
.upsert_user(tg_id, Some("GhostRunner".into()))
.await
.unwrap();
assert_eq!(user.balance, 0);
repo.add_balance(user.id, 5000).await.unwrap();
let updated_user = repo.get_user_by_id(user.id).await.unwrap().unwrap();
assert_eq!(updated_user.balance, 5000);
}
// =====================================================================
// ТЕСТ 2: Использование билетов (Cyberhack)
// =====================================================================
#[sqlx::test]
async fn test_consume_ticket_and_reward(pool: PgPool) {
let repo = PgRepository::new(pool.clone());
let tg_id = 888777666;
let user = repo.upsert_user(tg_id, None).await.unwrap();
assert_eq!(user.game_tickets, 0);
let reward_amount = 300;
let err = repo
.consume_ticket_and_reward(user.id, reward_amount)
.await
.unwrap_err();
assert_eq!(err, "NO_TICKETS", "Нельзя играть без токенов доступа");
let no_change_user = repo.get_user_by_id(user.id).await.unwrap().unwrap();
assert_eq!(no_change_user.balance, 0);
sqlx::query("UPDATE users SET game_tickets = 2 WHERE id = $1")
.bind(user.id)
.execute(&pool)
.await
.unwrap();
let success_now = repo
.consume_ticket_and_reward(user.id, reward_amount)
.await
.unwrap();
assert!(success_now);
let updated_user = repo.get_user_by_id(user.id).await.unwrap().unwrap();
assert_eq!(updated_user.game_tickets, 1);
assert_eq!(updated_user.balance, reward_amount);
}
// =====================================================================
// ТЕ==== ТЕСТ 3: Стресс-тест промокодов (Проверка блокировки FOR UPDATE)
// =====================================================================
#[sqlx::test]
async fn test_apply_promo_code_race_condition(pool: PgPool) {
let repo = Arc::new(PgRepository::new(pool.clone()));
let promo_code = "CYBER_RACE_2077";
let reward_amount = 1500i64;
sqlx::query(
"INSERT INTO promo_codes (code, reward_amount, max_uses, current_uses)
VALUES ($1, $2, 1, 0)",
)
.bind(promo_code)
.bind(reward_amount)
.execute(&pool)
.await
.unwrap();
let num_requests = 10;
let mut handles = vec![];
for i in 0..num_requests {
let repo_clone = repo.clone();
let code = promo_code.to_string();
let handle = tokio::spawn(async move {
let user = repo_clone.upsert_user(1000 + i, None).await.unwrap();
repo_clone.apply_promo_code(user.id, &code).await
});
handles.push(handle);
}
let mut success_count = 0;
let mut fail_count = 0;
for handle in handles {
let result = handle.await.unwrap();
match result {
Ok(reward) => {
assert_eq!(reward, reward_amount);
success_count += 1;
}
Err(e) => {
assert!(e == "CODE_DEPLETED" || e == "ALREADY_USED");
fail_count += 1;
}
}
}
assert_eq!(
success_count, 1,
"Блокировка FOR UPDATE должна пропустить только один поток"
);
assert_eq!(fail_count, num_requests - 1);
let promo_record = sqlx::query("SELECT current_uses FROM promo_codes WHERE code = $1")
.bind(promo_code)
.fetch_one(&pool)
.await
.unwrap();
let current_uses: i32 = promo_record.get("current_uses");
assert_eq!(current_uses, 1);
}