mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
test: L1 integration harness (Postgres-backed, fundless)
- 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.
This commit is contained in:
@@ -51,6 +51,29 @@ jobs:
|
|||||||
- run: cargo test --locked
|
- run: cargo test --locked
|
||||||
- run: cargo test --doc
|
- 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:
|
supply-chain:
|
||||||
name: cargo-deny
|
name: cargo-deny
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+17
@@ -18,6 +18,7 @@ pub mod models;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{routing::get, Router};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
@@ -34,3 +35,19 @@ pub struct AppState {
|
|||||||
/// a shared/stateless variant is the load-balanced-fleet path.
|
/// a shared/stateless variant is the load-balanced-fleet path.
|
||||||
pub web_challenges: Arc<RwLock<HashMap<String, WebChallenge>>>,
|
pub web_challenges: Arc<RwLock<HashMap<String, WebChallenge>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<AppState>) -> 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)
|
||||||
|
}
|
||||||
|
|||||||
+2
-11
@@ -6,11 +6,10 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{routing::get, Router};
|
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
|
||||||
use smirk_backend_core::{
|
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]
|
#[tokio::main]
|
||||||
@@ -57,15 +56,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let api_v1 = smirk_backend_core::api::auth::routes()
|
let app = build_router(state);
|
||||||
.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 listener = tokio::net::TcpListener::bind(&addr).await?;
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
tracing::info!("smirk-backend-core listening on http://{addr}");
|
tracing::info!("smirk-backend-core listening on http://{addr}");
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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<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 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<AppState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Value>,
|
||||||
|
) -> (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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user