forked from coracle/caravel
Add some tests
This commit is contained in:
Generated
+1
@@ -213,6 +213,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http 0.5.2",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+338
-7
@@ -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<String>,
|
||||
body: Option<Value>,
|
||||
) -> (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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod api;
|
||||
pub mod billing;
|
||||
pub mod infra;
|
||||
pub mod models;
|
||||
pub mod repo;
|
||||
pub mod robot;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user