Files
such-gitea 9536dd099c feat(premium): invoice through more than one processor
Premium could invoice through exactly one processor, so an operator
running BTCPay for BTC, LTC and Grin could not also take Monero or
Wownero: BTCPay has no Wownero support, and this operator runs Monero
through its own checkout rather than the BTCPay plugin.

xmrcheckout and wowcheckout implement the same two Greenfield invoice
routes, accept the same `token` auth and report the same statuses, so
the existing adapter serves them unchanged. What was missing was more
than one of them. Each is now an optional rail with its own URL, store
and key (PAYMENT_XMRCHECKOUT_* and PAYMENT_WOWCHECKOUT_*), all three
values or none, and a half-configured rail refuses to start and names
itself rather than advertising a coin that cannot be paid.

The invoice row already recorded which provider minted it; activation
now polls that one, never whichever is primary today. An invoice whose
rail was switched off is refused and left unconsumed, so turning the
rail back on lets the user redeem what they paid. A client that names
a rail the instance does not offer is refused rather than handed an
invoice in a different coin; a client that names none gets the
primary, which is how every client before this behaved.

Registration is untouched and keeps the primary. /capabilities lists
the rails and their assets, primary first, from config alone, so it
never depends on a processor being reachable. PAYMENT_ASSETS labels
the primary. Checkout rails also get an exact confirmationsRequired,
which BTCPay Server is not sent since it only has speedPolicy buckets.
2026-09-26 10:52:50 -04:00

315 lines
12 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 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<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 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)
}
}