mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
Wire the settings overlay into the live config path: - AppState.config: Config -> Arc<ArcSwap<Config>> (hot-swappable effective config) + config_base: Arc<Config> (immutable env-only). New AppState::cfg() accessor; every config read goes through it (state.cfg().field), so a runtime-safe operator edit is seen on the next request. One Config type, no parallel path. - Boot (main.rs): load_settings_overlay -> Config::apply_overlay(validate) -> build every client/worker/router from the EFFECTIVE config. A persisted overlay that no longer validates is ignored (boot env-only + loud log), never bricks. Env-only when ADMIN_KEY_INTEGRITY_SECRET is unset. - Migrated ~59 state.config.X reads to state.cfg().X across api/*; bound guards where a reference outlives the statement; integrity_secret returns owned String. - Test harness builds AppState with ArcSwap + config_base (arc-swap dev-dep). Behavior-preserving: with no operator_settings rows, effective == env config. cargo check --all-targets clean; lib tests 256/0.
279 lines
9.8 KiB
Rust
279 lines
9.8 KiB
Rust
//! 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<TestApp> {
|
|
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<TestApp> {
|
|
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 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,
|
|
sessions,
|
|
chains,
|
|
payment,
|
|
relay,
|
|
web_challenges: Arc::default(),
|
|
prices,
|
|
admin_sessions,
|
|
});
|
|
|
|
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<AppState>,
|
|
}
|
|
|
|
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<Value>,
|
|
) -> (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<Value>,
|
|
) -> (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<Value>,
|
|
) -> (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 + 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;
|
|
let pair = self
|
|
.state
|
|
.sessions
|
|
.create_token_pair(user_id, Platform::Web, Uuid::new_v4())
|
|
.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 {
|
|
user_id,
|
|
refresh_token_hash: hash,
|
|
platform: "web".to_string(),
|
|
device_info: None,
|
|
ip_address: None,
|
|
expires_at: chrono::Utc::now() + chrono::Duration::days(30),
|
|
})
|
|
.await
|
|
.expect("create session");
|
|
(user_id, pair.access_token, pair.refresh_token)
|
|
}
|
|
}
|