From 611e02e4dee0cf7ef5476c8c598cfdbe4cccfc21 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 22 Jun 2026 18:54:24 -0400 Subject: [PATCH] test: L1 integration harness (Postgres-backed, fundless) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared build_router() (lib) so main + tests exercise identical wiring - tests/common: spins the real router + AppState against TEST_DATABASE_URL; self-skips when unset (cargo test stays green without a DB). Deterministic, non-secret test JWT/peppers; ephemeral generated identities only — no funded or sensitive wallet seeds (those stay in a separate gitignored L3 harness). - tests/db_queries: validates migrations + db layer against real Postgres (peppered identity lookups, UNIQUE->409, session create/lookup/revoke, key upsert/list/delete, restore-attempt counts). - tests/auth_flow: HTTP flows through the real router (health, auth gate, refresh rotation + old-token revocation, username set+lookup, key register+ fetch, NIP-05 directory). - CI: new integration job with an ephemeral postgres:16 service. - 82 tests total (69 unit + 13 integration), all green against live PG. Complements the monorepo's client-side suite (it tests the wire format the client sends; the client tests the wallet side); mirrors its secret-handling (env-driven, gitignored). Mirrors the L0-L5 tiering from docs/engineering. --- .github/workflows/ci.yml | 23 +++++ src/lib.rs | 17 ++++ src/main.rs | 13 +-- tests/.env.example | 13 +++ tests/auth_flow.rs | 146 +++++++++++++++++++++++++++++++ tests/common/mod.rs | 184 +++++++++++++++++++++++++++++++++++++++ tests/db_queries.rs | 146 +++++++++++++++++++++++++++++++ 7 files changed, 531 insertions(+), 11 deletions(-) create mode 100644 tests/.env.example create mode 100644 tests/auth_flow.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/db_queries.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c274ef..29b8a57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,29 @@ jobs: - run: cargo test --locked - run: cargo test --doc + integration: + name: integration (postgres) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: smirk + POSTGRES_PASSWORD: smirk + POSTGRES_DB: smirk_test + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + env: + # Fundless L1 harness: a throwaway DB. The harness sets its own non-secret + # test JWT/peppers internally. + TEST_DATABASE_URL: postgres://smirk:smirk@localhost:5432/smirk_test + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --locked --test auth_flow --test db_queries + supply-chain: name: cargo-deny runs-on: ubuntu-latest diff --git a/src/lib.rs b/src/lib.rs index f2c634e..68fec43 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod models; use std::collections::HashMap; use std::sync::Arc; +use axum::{routing::get, Router}; use tokio::sync::RwLock; use crate::config::Config; @@ -34,3 +35,19 @@ pub struct AppState { /// a shared/stateless variant is the load-balanced-fleet path. pub web_challenges: Arc>>, } + +/// Assemble the full application router with every route mounted and state +/// applied. Shared by `main` and the integration-test harness so both exercise +/// the exact same wiring: `/health` and the NIP-05 directory at the root, the +/// authenticated wallet/identity API nested under `/api/v1`. +pub fn build_router(state: Arc) -> Router { + let api_v1 = api::auth::routes() + .merge(api::website::routes()) + .merge(api::users::routes()); + + Router::new() + .route("/health", get(api::health::health)) + .merge(api::nip05::routes()) + .nest("/api/v1", api_v1) + .with_state(state) +} diff --git a/src/main.rs b/src/main.rs index b13b49c..ae9fcf2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,11 +6,10 @@ use std::sync::Arc; -use axum::{routing::get, Router}; use sqlx::postgres::PgPoolOptions; use smirk_backend_core::{ - api::health, config::Config, core::session::SessionManager, infra::db::Database, AppState, + build_router, config::Config, core::session::SessionManager, infra::db::Database, AppState, }; #[tokio::main] @@ -57,15 +56,7 @@ async fn main() -> Result<(), Box> { }); } - let api_v1 = smirk_backend_core::api::auth::routes() - .merge(smirk_backend_core::api::website::routes()) - .merge(smirk_backend_core::api::users::routes()); - - let app = Router::new() - .route("/health", get(health::health)) - .merge(smirk_backend_core::api::nip05::routes()) - .nest("/api/v1", api_v1) - .with_state(state); + let app = build_router(state); let listener = tokio::net::TcpListener::bind(&addr).await?; tracing::info!("smirk-backend-core listening on http://{addr}"); diff --git a/tests/.env.example b/tests/.env.example new file mode 100644 index 0000000..7d8aaeb --- /dev/null +++ b/tests/.env.example @@ -0,0 +1,13 @@ +# Integration-test configuration (L1, fundless). +# +# Set TEST_DATABASE_URL to a throwaway Postgres database; the harness runs the +# migrations into it and exercises the real router + db layer. With it unset, +# integration tests self-skip and only the in-crate unit tests run. +# +# The harness sets its own deterministic, NON-SENSITIVE test secrets (JWT/peppers) +# internally — do not put real secrets or funded wallet seeds here. +# +# Local example (socket auth): +# createdb smirk_backend_core_test +# TEST_DATABASE_URL="postgresql:///smirk_backend_core_test?host=/var/run/postgresql" cargo test +TEST_DATABASE_URL= diff --git a/tests/auth_flow.rs b/tests/auth_flow.rs new file mode 100644 index 0000000..89ca84d --- /dev/null +++ b/tests/auth_flow.rs @@ -0,0 +1,146 @@ +//! L1 integration: HTTP flows through the real router against a real database. +//! Exercises routing, the auth gate, session refresh rotation, the username + +//! key endpoints, and the NIP-05 directory end to end. + +mod common; + +use axum::http::StatusCode; +use serde_json::json; +use uuid::Uuid; + +#[tokio::test] +async fn health_is_ok() { + let app = require_app!(); + let (status, body) = app.request("GET", "/health", None, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ok"); +} + +#[tokio::test] +async fn me_requires_a_token() { + let app = require_app!(); + let (status, _) = app.request("GET", "/api/v1/auth/me", None, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn me_returns_with_a_valid_token() { + let app = require_app!(); + let (_uid, access, _refresh) = app.mint_session().await; + let (status, body) = app + .request("GET", "/api/v1/auth/me", Some(&access), None) + .await; + assert_eq!(status, StatusCode::OK); + assert!(body.is_object()); +} + +#[tokio::test] +async fn refresh_rotates_and_revokes_the_old_token() { + let app = require_app!(); + let (_uid, _access, refresh) = app.mint_session().await; + + let (status, body) = app + .request( + "POST", + "/api/v1/auth/refresh", + None, + Some(json!({ "refresh_token": refresh })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(body["access_token"].as_str().is_some()); + assert!(body["refresh_token"].as_str().is_some()); + + // The old refresh token was revoked by the rotation: reuse is rejected. + let (status2, _) = app + .request( + "POST", + "/api/v1/auth/refresh", + None, + Some(json!({ "refresh_token": refresh })), + ) + .await; + assert_eq!(status2, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn set_username_then_lookup() { + let app = require_app!(); + let (_uid, access, _r) = app.mint_session().await; + // Username rule: 3-32 chars, lowercase [a-z0-9_]; keep the unique suffix short. + let name = format!("u{}", &Uuid::new_v4().simple().to_string()[..16]); + + let (status, _) = app + .request( + "POST", + "/api/v1/users/me/username", + Some(&access), + Some(json!({ "username": name })), + ) + .await; + assert_eq!(status, StatusCode::OK); + + let (s2, _body) = app + .request( + "GET", + &format!("/api/v1/users/by-username/{name}"), + None, + None, + ) + .await; + assert_eq!(s2, StatusCode::OK); +} + +#[tokio::test] +async fn set_username_requires_auth() { + let app = require_app!(); + let (status, _) = app + .request( + "POST", + "/api/v1/users/me/username", + None, + Some(json!({ "username": "whoever" })), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn register_key_then_fetch() { + let app = require_app!(); + let (uid, access, _r) = app.mint_session().await; + + let (status, _) = app + .request( + "POST", + "/api/v1/keys", + Some(&access), + Some(json!({ "asset": "btc", "public_key": "02deadbeef" })), + ) + .await; + assert_eq!(status, StatusCode::OK); + + let (s2, _body) = app + .request("GET", &format!("/api/v1/users/{uid}/keys"), None, None) + .await; + assert_eq!(s2, StatusCode::OK); +} + +#[tokio::test] +async fn nip05_unknown_name_is_empty() { + let app = require_app!(); + let (status, body) = app + .request( + "GET", + "/.well-known/nostr.json?name=does-not-exist-xyz", + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + let empty = body["names"] + .as_object() + .map(|m| m.is_empty()) + .unwrap_or(true); + assert!(empty, "unknown name must resolve to no entry"); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..938f772 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,184 @@ +//! 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::session::SessionManager, 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 { + 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 state = Arc::new(AppState { + config, + db, + sessions, + web_challenges: 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 { + /// Issue a JSON request and return `(status, parsed-body-or-Null)`. + pub async fn request( + &self, + method: &str, + uri: &str, + token: Option<&str>, + body: Option, + ) -> (StatusCode, Value) { + let mut builder = Request::builder().method(method).uri(uri); + 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 = self.router.clone().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) + } +} diff --git a/tests/db_queries.rs b/tests/db_queries.rs new file mode 100644 index 0000000..45a4042 --- /dev/null +++ b/tests/db_queries.rs @@ -0,0 +1,146 @@ +//! L1 integration: the db query layer against a real Postgres. Validates the +//! migrations, peppered identity columns, UNIQUE->409 mapping, and the +//! session/key lifecycles that unit tests cannot reach without a database. + +mod common; + +use chrono::{Duration, Utc}; +use smirk_backend_core::error::AppError; +use smirk_backend_core::models::db::{AssetType, NewSession, NewUser, NewUserKey}; +use uuid::Uuid; + +fn new_user(pubkey_hash: &str) -> NewUser { + NewUser { + username: None, + pubkey_hash: Some(pubkey_hash.to_string()), + nostr_pubkey: None, + wallet_birthday: None, + seed_fingerprint: None, + xmr_start_height: None, + wow_start_height: None, + } +} + +#[tokio::test] +async fn create_user_then_peppered_lookup() { + let app = require_app!(); + let db = &app.state.db; + let raw = format!("pk-{}", Uuid::new_v4()); + + let user = db.create_user(new_user(&raw)).await.unwrap(); + + // Raw pubkey hash resolves the row (peppering is applied symmetrically). + let found = db + .get_user_by_pubkey_hash(&raw) + .await + .unwrap() + .expect("resolved by pubkey_hash"); + assert_eq!(found.id, user.id); + + // The value stored at rest is peppered, NOT the raw input (seed-oracle fix). + assert_ne!(found.pubkey_hash.as_deref(), Some(raw.as_str())); + + assert_eq!( + db.get_user_by_id(user.id).await.unwrap().unwrap().id, + user.id + ); +} + +#[tokio::test] +async fn username_unique_violation_is_conflict() { + let app = require_app!(); + let db = &app.state.db; + let name = format!("u{}", Uuid::new_v4().simple()); + + let a = db + .create_user(new_user(&format!("pk-{}", Uuid::new_v4()))) + .await + .unwrap(); + let b = db + .create_user(new_user(&format!("pk-{}", Uuid::new_v4()))) + .await + .unwrap(); + + db.set_username(a.id, &name).await.unwrap(); + let err = db.set_username(b.id, &name).await.unwrap_err(); + assert!( + matches!(err, AppError::Conflict(_)), + "expected 409 Conflict, got {err:?}" + ); +} + +#[tokio::test] +async fn session_create_lookup_revoke() { + let app = require_app!(); + let db = &app.state.db; + let user = db + .create_user(new_user(&format!("pk-{}", Uuid::new_v4()))) + .await + .unwrap(); + let hash = format!("rh-{}", Uuid::new_v4()); + + let sess = db + .create_session(NewSession { + user_id: user.id, + refresh_token_hash: hash.clone(), + platform: "extension".to_string(), + device_info: None, + ip_address: None, + expires_at: Utc::now() + Duration::days(30), + }) + .await + .unwrap(); + + assert!(db.get_session_by_token_hash(&hash).await.unwrap().is_some()); + + // First revoke succeeds (was active); the lookup then excludes it. + assert!(db.revoke_session(sess.id).await.unwrap()); + assert!(db.get_session_by_token_hash(&hash).await.unwrap().is_none()); + // Second revoke is a no-op (already revoked) -> false, disambiguating races. + assert!(!db.revoke_session(sess.id).await.unwrap()); +} + +#[tokio::test] +async fn user_key_upsert_list_delete() { + let app = require_app!(); + let db = &app.state.db; + let user = db + .create_user(new_user(&format!("pk-{}", Uuid::new_v4()))) + .await + .unwrap(); + + let mk = |pk: &str| NewUserKey { + user_id: user.id, + asset: AssetType::Btc, + public_key: pk.to_string(), + public_spend_key: None, + key_type: "primary".to_string(), + }; + + db.upsert_user_key(mk("pk1")).await.unwrap(); + // Same (user, asset, key_type) upserts in place rather than duplicating. + db.upsert_user_key(mk("pk2")).await.unwrap(); + + let keys = db.get_user_keys(user.id).await.unwrap(); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].public_key, "pk2"); + + assert!(db.delete_user_key(user.id, AssetType::Btc).await.unwrap()); + assert!(db.get_user_keys(user.id).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn restore_attempts_count_and_pepper() { + let app = require_app!(); + let db = &app.state.db; + let fp = format!("fp-{}", Uuid::new_v4()); + + assert_eq!(db.count_failed_restore_attempts(&fp).await.unwrap(), 0); + db.record_restore_attempt(&fp, Some("203.0.113.7"), false) + .await + .unwrap(); + db.record_restore_attempt(&fp, Some("203.0.113.7"), false) + .await + .unwrap(); + assert_eq!(db.count_failed_restore_attempts(&fp).await.unwrap(), 2); +}