From 6faf7d3fa0bacdbd21a78d7573408147fd7f89dd Mon Sep 17 00:00:00 2001 From: userAdityaa Date: Mon, 13 Apr 2026 23:17:20 +0545 Subject: [PATCH] test(billing): validate stripe customer id enforcement --- backend/src/billing.rs | 106 +++++++++++++++++++++++++++++++++++++++++ backend/src/command.rs | 53 +++++++++++++++++++++ backend/src/robot.rs | 20 ++++++++ 3 files changed, 179 insertions(+) diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 6d13f14..a5f60fa 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -902,3 +902,109 @@ mod tests { )); } } + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::SqlitePool; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::str::FromStr; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + #[allow(unused_unsafe)] + fn set_stripe_secret_key(value: Option<&str>) { + match value { + Some(v) => unsafe { std::env::set_var("STRIPE_SECRET_KEY", v) }, + None => unsafe { std::env::remove_var("STRIPE_SECRET_KEY") }, + } + } + + struct StripeSecretKeyGuard { + previous: Option, + } + + impl StripeSecretKeyGuard { + fn set(value: Option<&str>) -> Self { + let previous = std::env::var("STRIPE_SECRET_KEY").ok(); + set_stripe_secret_key(value); + Self { previous } + } + } + + impl Drop for StripeSecretKeyGuard { + fn drop(&mut self) { + set_stripe_secret_key(self.previous.as_deref()); + } + } + + async fn test_pool() -> SqlitePool { + let connect_options = SqliteConnectOptions::from_str("sqlite::memory:") + .expect("valid sqlite memory url") + .create_if_missing(true); + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(connect_options) + .await + .expect("connect sqlite memory db"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run migrations"); + + pool + } + + #[tokio::test] + async fn billing_new_panics_without_stripe_secret_key() { + let _lock = env_lock().lock().expect("acquire env lock"); + let _env = StripeSecretKeyGuard::set(None); + + let pool = test_pool().await; + let query = Query::new(pool.clone()); + let command = Command::new(pool); + let robot = Robot::test_stub(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + Billing::new(query, command, robot) + })); + + let panic_payload = match result { + Ok(_) => panic!("constructor should panic when STRIPE_SECRET_KEY is missing"), + Err(payload) => payload, + }; + let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() { + (*msg).to_string() + } else if let Some(msg) = panic_payload.downcast_ref::() { + msg.clone() + } else { + String::new() + }; + + assert!( + panic_msg.contains("missing STRIPE_SECRET_KEY environment variable"), + "unexpected panic: {panic_msg}" + ); + } + + #[tokio::test] + async fn billing_new_accepts_non_empty_stripe_secret_key() { + let _lock = env_lock().lock().expect("acquire env lock"); + let _env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); + + let pool = test_pool().await; + let billing = Billing::new( + Query::new(pool.clone()), + Command::new(pool), + Robot::test_stub(), + ); + + assert_eq!(billing.stripe_secret_key, "sk_test_dummy"); + } +} diff --git a/backend/src/command.rs b/backend/src/command.rs index aeba583..031977e 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -332,3 +332,56 @@ impl Command { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::SqlitePool; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::str::FromStr; + + async fn test_pool() -> SqlitePool { + let connect_options = SqliteConnectOptions::from_str("sqlite::memory:") + .expect("valid sqlite memory url") + .create_if_missing(true); + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(connect_options) + .await + .expect("connect sqlite memory db"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run migrations"); + + pool + } + + #[tokio::test] + async fn create_tenant_rejects_empty_stripe_customer_id() { + let pool = test_pool().await; + let command = Command::new(pool); + + let tenant = Tenant { + pubkey: "tenant_pubkey".to_string(), + nwc_url: String::new(), + nwc_error: None, + created_at: 0, + stripe_customer_id: " ".to_string(), + stripe_subscription_id: None, + past_due_at: None, + }; + + let err = command + .create_tenant(&tenant) + .await + .expect_err("empty customer id must be rejected"); + + assert!( + err.to_string().contains("stripe_customer_id is required"), + "unexpected error: {err}" + ); + } +} diff --git a/backend/src/robot.rs b/backend/src/robot.rs index e002a31..e8195db 100644 --- a/backend/src/robot.rs +++ b/backend/src/robot.rs @@ -254,3 +254,23 @@ async fn set_cached( }, ); } + +#[cfg(test)] +impl Robot { + pub fn test_stub() -> Self { + let keys = Keys::generate(); + let client = Client::new(keys); + + Self { + secret: String::new(), + name: String::new(), + description: String::new(), + picture: String::new(), + outbox_client: client.clone(), + indexer_client: client.clone(), + messaging_client: client, + outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), + dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), + } + } +}