//! Integration-test harness (L1: access + read, fundless). //! //! Spins the real router + `AppState` against a Postgres database named by //! `TEST_DATABASE_URL`. When that is unset the helpers return `None` and tests //! self-skip, so `cargo test` stays green without a database; CI provides one. //! //! It uses only deterministic, non-sensitive TEST secrets and ephemeral //! generated identities — never a funded or otherwise sensitive wallet seed. //! (Funded, two-party L3+ scenarios live in a separate, gitignored harness.) #![allow(dead_code)] // helpers are shared across several test binaries use std::sync::{Arc, Once}; use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use axum::Router; use serde_json::Value; use sqlx::postgres::PgPoolOptions; use tower::ServiceExt; use uuid::Uuid; use smirk_backend_core::{ build_router, config::Config, core::admin_session::AdminSessionManager, core::session::SessionManager, infra::chains::ChainClients, infra::db::Database, AppState, }; static INIT_ENV: Once = Once::new(); /// Set deterministic, non-sensitive test secrets once, so the real (validated) /// `Config::from_env` path can build a test config. These are not credentials. fn init_test_env(database_url: &str) { INIT_ENV.call_once(|| { std::env::set_var("DATABASE_URL", database_url); std::env::set_var("ENVIRONMENT", "development"); std::env::set_var("JWT_SECRET", "integration-test-jwt-secret-0123456789ab"); std::env::set_var( "SEED_FINGERPRINT_PEPPER", "integration-test-seed-pepper-0123456789ab", ); std::env::set_var( "REFRESH_TOKEN_PEPPER", "integration-test-refresh-pepper-0123456789ab", ); std::env::set_var("IP_SALT", "integration-test-ip-salt-0123456789"); std::env::set_var("PUBLIC_API_URL", "http://localhost:8080/api/v1"); }); } /// The router + state under test, or `None` if no `TEST_DATABASE_URL` is set. pub async fn try_app() -> Option { try_app_with(|_| {}).await } /// Like [`try_app`], but lets a test tweak the validated `Config` before the router /// is built — e.g. restrict `cors_allowed_origins` to assert per-route CORS policy. pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option { let url = std::env::var("TEST_DATABASE_URL").ok()?; init_test_env(&url); let mut config = Config::from_env().expect("valid test config"); mutate(&mut config); let pool = PgPoolOptions::new() .max_connections(5) .connect(&config.database_url) .await .expect("connect test database"); sqlx::migrate!("./migrations") .run(&pool) .await .expect("run migrations"); let db = Database::new( pool, config.secrets.seed_fingerprint_pepper.clone(), config.secrets.ip_salt.clone(), ); let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours); let chains = ChainClients::from_config(&config).expect("build chain clients"); let payment = smirk_backend_core::infra::payment::from_config(&config).expect("build payment provider"); let payment_rails = smirk_backend_core::infra::payment::rails_from_config(&config, payment.as_ref()) .expect("build payment rails"); let relay = smirk_backend_core::infra::relay::from_config(&config).expect("build relay provider"); let prices = Arc::new(tokio::sync::RwLock::new( smirk_backend_core::infra::prices::PriceSnapshot::empty(&config.features.prices_currency), )); let admin_sessions = config .admin .enabled .then(|| AdminSessionManager::new(&config.admin.jwt_secret)); let state = Arc::new(AppState { config: Arc::new(arc_swap::ArcSwap::from_pointee(config.clone())), config_base: Arc::new(config), db, // Off in tests: the migration is a production transitional path, and a // test that needed it would configure a legacy database of its own. legacy: smirk_backend_core::infra::legacy_directory::LegacyDirectory::disabled(), sessions, chains, payment, payment_rails, relay, web_challenges: Arc::default(), prices, stats_cache: Arc::default(), admin_sessions, shutdown: Arc::default(), }); Some(TestApp { router: build_router(state.clone()), state, }) } /// Skip the current test (printing why) when no test DB is configured. #[macro_export] macro_rules! require_app { () => {{ match $crate::common::try_app().await { Some(app) => app, None => { eprintln!("skipping: TEST_DATABASE_URL not set"); return; } } }}; } pub struct TestApp { pub router: Router, pub state: Arc, } impl TestApp { /// The loopback admin-plane router (served separately from the public one). pub fn admin_router(&self) -> Router { smirk_backend_core::admin_router(self.state.clone()) } /// Issue a JSON request against the public router. pub async fn request( &self, method: &str, uri: &str, token: Option<&str>, body: Option, ) -> (StatusCode, Value) { self.request_on(self.router.clone(), method, uri, token, body) .await } /// Issue a JSON request against an arbitrary router (e.g. the admin plane). pub async fn request_on( &self, router: Router, method: &str, uri: &str, token: Option<&str>, body: Option, ) -> (StatusCode, Value) { // The per-IP rate limiter keys on ConnectInfo; in production `main` // supplies it via into_make_service_with_connect_info. oneshot requests // don't, so inject a loopback peer (each test builds a fresh router with // fresh limiter state, so this never trips across tests). let mut builder = Request::builder() .method(method) .uri(uri) .extension(axum::extract::ConnectInfo(std::net::SocketAddr::from(( [127, 0, 0, 1], 0, )))); if let Some(t) = token { builder = builder.header("authorization", format!("Bearer {t}")); } let req = match body { Some(b) => builder .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&b).unwrap())) .unwrap(), None => builder.body(Body::empty()).unwrap(), }; let resp = router.oneshot(req).await.unwrap(); let status = resp.status(); let bytes = to_bytes(resp.into_body(), 1 << 20).await.unwrap(); let json = if bytes.is_empty() { Value::Null } else { serde_json::from_slice(&bytes).unwrap_or(Value::Null) }; (status, json) } /// Issue a request with arbitrary extra request headers (e.g. an `Origin` for /// CORS assertions) and return the response STATUS + HEADERS + body. The plain /// `request` helper drops headers, which hid CORS/security-header regressions. pub async fn request_full( &self, method: &str, uri: &str, extra_headers: &[(&str, &str)], body: Option, ) -> (StatusCode, axum::http::HeaderMap, Value) { let mut builder = Request::builder() .method(method) .uri(uri) .extension(axum::extract::ConnectInfo(std::net::SocketAddr::from(( [127, 0, 0, 1], 0, )))); for (k, v) in extra_headers { builder = builder.header(*k, *v); } let req = match body { Some(b) => builder .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&b).unwrap())) .unwrap(), None => builder.body(Body::empty()).unwrap(), }; let resp = self.router.clone().oneshot(req).await.unwrap(); let status = resp.status(); let headers = resp.headers().clone(); let bytes = to_bytes(resp.into_body(), 1 << 20).await.unwrap(); let json = if bytes.is_empty() { Value::Null } else { serde_json::from_slice(&bytes).unwrap_or(Value::Null) }; (status, headers, json) } /// Create a fresh user with a random identity. Returns the user id. pub async fn create_user(&self) -> Uuid { use smirk_backend_core::models::db::NewUser; let user = self .state .db .create_user(NewUser { username: None, pubkey_hash: Some(format!("pk-{}", Uuid::new_v4())), nostr_pubkey: None, wallet_birthday: None, seed_fingerprint: None, xmr_start_height: None, wow_start_height: None, }) .await .expect("create user"); user.id } /// Create a user holding a claimed handle. Returns the handle so a test can /// assert it never appears on a public surface. pub async fn create_user_with_handle(&self) -> String { use smirk_backend_core::models::db::NewUser; // Shaped like a real handle (lowercase, <= 32 chars) but unmistakable in // a response body, so a leak is a match and not a coincidence. let handle = format!("h{}", &Uuid::new_v4().simple().to_string()[..16]); self.state .db .create_user(NewUser { username: Some(handle.clone()), pubkey_hash: Some(format!("pk-{}", Uuid::new_v4())), nostr_pubkey: None, wallet_birthday: None, seed_fingerprint: None, xmr_start_height: None, wow_start_height: None, }) .await .expect("create user with handle"); handle } /// Create a user + an active session, returning `(user_id, access, refresh)`. /// Bypasses the signed-registration handshake so session-gated routes can be /// exercised without forging a BIP-137 signature. pub async fn mint_session(&self) -> (Uuid, String, String) { use smirk_backend_core::core::session::{hash_refresh_token, Platform}; use smirk_backend_core::models::db::NewSession; let user_id = self.create_user().await; // The access token embeds this as `sid`, so the row MUST carry the same id // or every authenticated request fails the session-liveness check. let session_id = Uuid::new_v4(); let pair = self .state .sessions .create_token_pair(user_id, Platform::Web, session_id) .expect("mint token pair"); let hash = hash_refresh_token( &pair.refresh_token, &self.state.cfg().secrets.refresh_token_pepper, ); self.state .db .create_session(NewSession { id: session_id, user_id, refresh_token_hash: hash, platform: "web".to_string(), device_info: None, expires_at: chrono::Utc::now() + chrono::Duration::days(30), }) .await .expect("create session"); (user_id, pair.access_token, pair.refresh_token) } }