Files
smirk-backend-core/tests/common/mod.rs
T
such-gitea 985a05adf8 fix(website): resolve sign-in by the asset that signed, not only the BTC hash
website_verify resolved every asset through get_user_by_pubkey_hash, but
users.pubkey_hash only ever holds the BTC key's hash. On this backend only BTC
could ever resolve; ltc, xmr, wow and grin were structurally impossible, and the
failure was indistinguishable from an unregistered user, so the page told people
to register with an extension they already had.

That did not surface while smirk.cash pointed at the legacy backend, which
resolves by asset key against its own database. It becomes the whole bug the
moment the site is repointed here, so it has to land with that change, not after.

get_user_by_pubkey_hash stays as the BTC fallback: of 95 users on this backend,
84 arrived through migrate-legacy, which imported identity without keys, and
would otherwise stop resolving entirely.

The rejection message is unchanged and still says nothing about whether an
account exists.
2026-09-14 22:07:20 -04:00

308 lines
11 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,
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)
}
}