From 8dfa09d22edf2e5debd22c68c1f1194fd00f889a Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 26 Mar 2026 15:06:30 -0700 Subject: [PATCH] Add some tests --- backend/Cargo.lock | 1 + backend/Cargo.toml | 3 + backend/src/api.rs | 345 ++++++++++++++++++++++++++++++++++++++++++++- backend/src/lib.rs | 6 + justfile | 22 ++- 5 files changed, 363 insertions(+), 14 deletions(-) create mode 100644 backend/src/lib.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index e376515..915d400 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -213,6 +213,7 @@ dependencies = [ "serde_json", "sqlx", "tokio", + "tower", "tower-http 0.5.2", "tracing", "tracing-subscriber", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1cd8e03..9916b98 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -21,3 +21,6 @@ rand = "0.8" hex = "0.4" dotenvy = "0.15.7" base64 = "0.22" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } diff --git a/backend/src/api.rs b/backend/src/api.rs index d5f4e1c..d727d9c 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -86,11 +86,20 @@ impl Api { } pub async fn serve(&self) -> Result<()> { + let app = self.router(); + + let listener = + tokio::net::TcpListener::bind(format!("{}:{}", self.host, self.port)).await?; + axum::serve(listener, app).await?; + Ok(()) + } + + fn router(&self) -> Router { let state = AppState { api: Arc::new(self.clone()), }; - let app = Router::new() + Router::new() .route("/plans", get(list_plans)) .route("/plans/:id", get(get_plan)) .route("/tenants", get(list_tenants).post(create_tenant)) @@ -104,12 +113,7 @@ impl Api { .route("/invoices", get(list_invoices)) .route("/invoices/:id", get(get_invoice)) .with_state(state) - .layer(self.cors_layer()); - - let listener = - tokio::net::TcpListener::bind(format!("{}:{}", self.host, self.port)).await?; - axum::serve(listener, app).await?; - Ok(()) + .layer(self.cors_layer()) } fn cors_layer(&self) -> CorsLayer { @@ -747,3 +751,330 @@ async fn update_tenant_billing( )), } } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, + }; + use base64::Engine; + use nostr_sdk::{EventBuilder, JsonUtil, Keys, Kind, Tag}; + use serde_json::{Value, json}; + use sqlx::{SqlitePool, sqlite::{SqliteConnectOptions, SqlitePoolOptions}}; + use tower::util::ServiceExt; + + use crate::{models::{Relay, Tenant}, repo::Repo}; + + use super::Api; + + async fn test_repo() -> Repo { + let db_file = std::env::temp_dir().join(format!("caravel-api-test-{}.db", uuid::Uuid::new_v4())); + let database_url = format!("sqlite://{}", db_file.display()); + + let options = SqliteConnectOptions::from_str(&database_url) + .expect("sqlite options") + .create_if_missing(true); + let pool: SqlitePool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("connect sqlite"); + + sqlx::query("PRAGMA journal_mode = WAL;") + .execute(&pool) + .await + .expect("set WAL"); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run migrations"); + + Repo { pool } + } + + fn keys() -> (Keys, Keys, Keys) { + (Keys::generate(), Keys::generate(), Keys::generate()) + } + + fn pubkey_hex(keys: &Keys) -> String { + keys.public_key().to_hex() + } + + fn auth_header(keys: &Keys, u: &str) -> String { + let tag = Tag::parse(["u", u]).expect("u tag"); + let event = EventBuilder::new(Kind::HttpAuth, "").tags([tag]) + .sign_with_keys(keys) + .expect("sign nip98 event"); + let json = event.as_json(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json); + format!("Nostr {b64}") + } + + fn make_api(repo: Repo, admin_pubkey: String) -> Api { + Api { + host: "api.test".to_string(), + port: 0, + admins: vec![admin_pubkey], + origins: vec![], + repo, + } + } + + async fn request( + api: &Api, + method: &str, + path: &str, + auth: Option, + body: Option, + ) -> (StatusCode, Value) { + let mut builder = Request::builder().method(method).uri(path); + if let Some(auth) = auth { + builder = builder.header(header::AUTHORIZATION, auth); + } + + let req = if let Some(body) = body { + builder + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .expect("request") + } else { + builder.body(Body::empty()).expect("request") + }; + + let resp = api.router().oneshot(req).await.expect("router response"); + let status = resp.status(); + let bytes = to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let payload: Value = serde_json::from_slice(&bytes).expect("json body"); + (status, payload) + } + + async fn create_tenant(repo: &Repo, pubkey: String) { + let tenant = Tenant { + pubkey, + nwc_url: String::new(), + created_at: 1, + billing_anchor: 1, + }; + repo.create_tenant(&tenant).await.expect("create tenant"); + } + + async fn create_relay(repo: &Repo, id: &str, tenant: &str, subdomain: &str) { + let relay = Relay { + id: id.to_string(), + tenant: tenant.to_string(), + schema: format!("{}_{}", subdomain.replace('-', "_"), id), + subdomain: subdomain.to_string(), + plan: "free".to_string(), + status: "new".to_string(), + sync_error: String::new(), + info_name: String::new(), + info_icon: String::new(), + info_description: String::new(), + policy_public_join: 0, + policy_strip_signatures: 0, + groups_enabled: 1, + management_enabled: 1, + blossom_enabled: 0, + livekit_enabled: 0, + push_enabled: 1, + }; + repo.create_relay(&relay).await.expect("create relay"); + } + + #[tokio::test] + async fn missing_auth_returns_unauthorized() { + let repo = test_repo().await; + let (admin_keys, _, _) = keys(); + let api = make_api(repo, pubkey_hex(&admin_keys)); + + let (status, body) = request(&api, "GET", "/plans", None, None).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "unauthorized"); + } + + #[tokio::test] + async fn plans_endpoints_return_ok_and_not_found() { + let repo = test_repo().await; + let (admin_keys, _, _) = keys(); + let api = make_api(repo, pubkey_hex(&admin_keys)); + let auth = auth_header(&admin_keys, "https://api.test"); + + let (status, body) = request(&api, "GET", "/plans", Some(auth.clone()), None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["code"], "ok"); + assert!(body["data"].as_array().expect("plans array").len() >= 3); + + let (status, body) = request(&api, "GET", "/plans/does-not-exist", Some(auth), None).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["code"], "not-found"); + } + + #[tokio::test] + async fn tenants_list_is_admin_only() { + let repo = test_repo().await; + let (admin_keys, tenant_keys, _) = keys(); + let api = make_api(repo, pubkey_hex(&admin_keys)); + + let auth = auth_header(&tenant_keys, "https://api.test"); + let (status, body) = request(&api, "GET", "/tenants", Some(auth), None).await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "forbidden"); + } + + #[tokio::test] + async fn create_tenant_and_duplicate_returns_expected_codes() { + let repo = test_repo().await; + let (admin_keys, tenant_keys, _) = keys(); + let api = make_api(repo, pubkey_hex(&admin_keys)); + let auth = auth_header(&tenant_keys, "https://api.test"); + + let (status, body) = request(&api, "POST", "/tenants", Some(auth.clone()), None).await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(body["code"], "ok"); + assert_eq!(body["data"]["pubkey"], pubkey_hex(&tenant_keys)); + + let (status, body) = request(&api, "POST", "/tenants", Some(auth), None).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body["code"], "pubkey-exists"); + } + + #[tokio::test] + async fn tenant_get_allows_owner_and_denies_other_tenant() { + let repo = test_repo().await; + let (admin_keys, tenant_a_keys, tenant_b_keys) = keys(); + create_tenant(&repo, pubkey_hex(&tenant_a_keys)).await; + let api = make_api(repo, pubkey_hex(&admin_keys)); + + let owner_auth = auth_header(&tenant_a_keys, "https://api.test"); + let (status, body) = request( + &api, + "GET", + &format!("/tenants/{}", pubkey_hex(&tenant_a_keys)), + Some(owner_auth), + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["code"], "ok"); + + let other_auth = auth_header(&tenant_b_keys, "https://api.test"); + let (status, body) = request( + &api, + "GET", + &format!("/tenants/{}", pubkey_hex(&tenant_a_keys)), + Some(other_auth), + None, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "forbidden"); + } + + #[tokio::test] + async fn create_relay_validates_premium_feature_and_subdomain() { + let repo = test_repo().await; + let (admin_keys, tenant_keys, _) = keys(); + create_tenant(&repo, pubkey_hex(&tenant_keys)).await; + let api = make_api(repo, pubkey_hex(&admin_keys)); + let auth = auth_header(&tenant_keys, "https://api.test"); + + let (status, body) = request( + &api, + "POST", + "/relays", + Some(auth.clone()), + Some(json!({ + "tenant": pubkey_hex(&tenant_keys), + "subdomain": "bad_subdomain", + "plan": "free" + })), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body["code"], "invalid-relay"); + + let (status, body) = request( + &api, + "POST", + "/relays", + Some(auth), + Some(json!({ + "tenant": pubkey_hex(&tenant_keys), + "subdomain": "good-subdomain", + "plan": "free", + "blossom_enabled": 1 + })), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body["code"], "premium-feature"); + } + + #[tokio::test] + async fn relay_get_honors_owner_and_duplicate_subdomain_is_422() { + let repo = test_repo().await; + let (admin_keys, tenant_a_keys, tenant_b_keys) = keys(); + let tenant_a = pubkey_hex(&tenant_a_keys); + create_tenant(&repo, tenant_a.clone()).await; + create_tenant(&repo, pubkey_hex(&tenant_b_keys)).await; + create_relay(&repo, "relay-a", &tenant_a, "alpha").await; + + let api = make_api(repo.clone(), pubkey_hex(&admin_keys)); + let owner_auth = auth_header(&tenant_a_keys, "https://api.test"); + + let (status, body) = request(&api, "GET", "/relays/relay-a", Some(owner_auth), None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["code"], "ok"); + + let other_auth = auth_header(&tenant_b_keys, "https://api.test"); + let (status, body) = request(&api, "GET", "/relays/relay-a", Some(other_auth), None).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "forbidden"); + + let (status, body) = request( + &api, + "POST", + "/relays", + Some(auth_header(&tenant_a_keys, "https://api.test")), + Some(json!({ + "tenant": tenant_a, + "subdomain": "alpha", + "plan": "free" + })), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body["code"], "subdomain-exists"); + } + + #[tokio::test] + async fn update_tenant_billing_allows_self() { + let repo = test_repo().await; + let (admin_keys, tenant_keys, _) = keys(); + let tenant = pubkey_hex(&tenant_keys); + create_tenant(&repo, tenant.clone()).await; + + let api = make_api(repo.clone(), pubkey_hex(&admin_keys)); + let auth = auth_header(&tenant_keys, "https://api.test"); + + let (status, body) = request( + &api, + "PUT", + &format!("/tenants/{tenant}/billing"), + Some(auth), + Some(json!({"nwc_url":"nostr+walletconnect://example"})), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["code"], "ok"); + assert_eq!(body["data"]["nwc_url"], "nostr+walletconnect://example"); + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..7b16abe --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,6 @@ +pub mod api; +pub mod billing; +pub mod infra; +pub mod models; +pub mod repo; +pub mod robot; diff --git a/justfile b/justfile index 39ec917..c11ff72 100644 --- a/justfile +++ b/justfile @@ -8,25 +8,33 @@ dev: dev-frontend: cd frontend && bun run dev -build-frontend: - cd frontend && bun run build - preview-frontend: cd frontend && bun run preview fmt-backend: cd backend && cargo fmt -fmt: fmt-backend - lint-backend: cd backend && cargo clippy -- -D warnings -lint: lint-backend +test-backend: + cd backend && cargo test + +test-backend-api: + cd backend && cargo test api::tests:: build-backend: cd backend && cargo build +build-frontend: + cd frontend && bun run build + +fmt: fmt-backend + +lint: lint-backend + build: build-backend build-frontend -check: fmt lint build +test: test-backend + +check: fmt lint build test