Files
smirk-backend-core/tests/common/mod.rs
T
jwinterm 54ae9fadac feat(registration): pay-to-register via pluggable payment provider (gate #3)
Optional REGISTRATION_REQUIRE_PAYMENT gate: a new wallet must settle an
invoice on an external, non-custodial payment processor before it can
register. Pull model — the grant reads the processor's authenticated API as
the source of truth, so there is no inbound webhook to secure and a settled
invoice cannot be forged over HTTP. Returning wallets and self-hosting bypass
it, exactly like the invite and PoW gates.

- infra/payment: a pluggable PaymentProvider seam (neutral Invoice /
  InvoiceStatus / InvoiceRequest) with a BTCPay-compatible first adapter that
  also serves this project's xmrcheckout/wowcheckout apps + the BTCPay Monero
  plugin, so "operator picks any coin" costs one adapter. A future ETH/native
  adapter (BTCPay serves neither) implements the same trait. Only `Settled`
  grants (unknown status fails safe); the API key rides in a redacting Secret
  and hostile responses are size-capped.
- payment_invoices: invoice<->pubkey_hash binding + atomic single-use consume
  (UPDATE ... WHERE consumed_at IS NULL AND pubkey_hash=$ RETURNING), so one
  invoice grants at most one registration and never a different identity.
- POST /auth/payment-invoice mints + binds; enforce_payment (bind -> processor
  `Settled` -> atomic consume) runs beside enforce_invite; payment_invoice_id
  on the register request.
- Fail-closed config: https-in-prod, positive-decimal price, >= 1 confirmation
  (bars a 0-conf settled-then-double-spent free registration), bounded expiry.
  /capabilities advertises payment_required + price; OpenAPI + .env.example.

Tests: adapter status/speedPolicy mapping + config validation (unit), and L1
binding + atomic single-use (concurrent-consume-one-wins, mis-bind rejected).
Reviewed adversarially across payment-integrity, leak/oracle, and config/DoS
lenses; the confirmed findings (0-conf, expiry bound, Settled-means-paid-in-full
adapter contract, rotation-branch bypass guard) are addressed here.
2026-06-30 21:30:12 -04:00

229 lines
7.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> {
let url = std::env::var("TEST_DATABASE_URL").ok()?;
init_test_env(&url);
let config = Config::from_env().expect("valid test 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 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,
db,
sessions,
chains,
payment,
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)
}
/// 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.config.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)
}
}