//! API endpoint integration tests. //! //! Drives real HTTP requests through the daemon's `axum::Router` against //! a real SQLite database (per-test tempfile, identical pool options to //! `src/db/mod.rs::init`). Companion to `tests/migrations.rs`: that file //! tested schema correctness; this one tests endpoint correctness. //! //! These tests bypass `main.rs`'s env-var bootstrap and skip background //! workers (reconcile, webhook delivery, session reaper). They construct //! `AppState` programmatically with deterministic values so the same //! pool, signing key, and admin token are reachable from inside the test //! body. use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use axum::response::Response; use chrono::Utc; use keysat::api::{self, AppState}; use keysat::config::Config; use keysat::crypto::{self, LicensePayload}; use keysat::db::repo; use keysat::license_self::Tier; use serde_json::{json, Value}; use sqlx::sqlite::{ SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteSynchronous, }; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; use tempfile::NamedTempFile; use tokio::sync::RwLock; use tower::ServiceExt; use uuid::Uuid; /// Deterministic admin token used by every test that exercises an admin /// endpoint. ≥32 chars to satisfy `Config::from_env`'s validation rule /// (we don't go through that path here, but matching the constraint /// keeps fixtures realistic). const TEST_ADMIN_KEY: &str = "test_admin_api_key_with_at_least_32_chars_present"; // --------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------- /// Open a fresh pool against a throwaway tempfile, mirroring /// `src/db/mod.rs::init` exactly. `NamedTempFile` is returned so the /// caller keeps it alive for the test's lifetime — when it drops, the /// OS reclaims the file. async fn make_pool() -> (SqlitePool, NamedTempFile) { let tmp = NamedTempFile::new().expect("create tempfile"); let url = format!("sqlite://{}", tmp.path().display()); let opts = SqliteConnectOptions::from_str(&url) .expect("parse sqlite url") .create_if_missing(true) .journal_mode(SqliteJournalMode::Wal) .synchronous(SqliteSynchronous::Normal) .foreign_keys(true) .busy_timeout(Duration::from_secs(5)); let pool = SqlitePoolOptions::new() .max_connections(2) .connect_with(opts) .await .expect("connect to sqlite"); sqlx::migrate!("./migrations") .run(&pool) .await .expect("run migrations"); (pool, tmp) } /// Build a fully-populated `AppState` ready to serve requests. Skips /// `main.rs`'s env-var bootstrap and never spawns background workers — /// these tests only exercise the request/response handler chain. /// /// - `payment` is `None`. Endpoints that require a payment provider /// (e.g. `POST /v1/purchase`) will return 503; tests below don't drive /// those paths. /// - `self_tier = Tier::Unlicensed` inherits Creator-tier caps (5 /// products, 5 codes, etc.). Plenty for the small fixtures here. async fn make_test_state() -> (AppState, NamedTempFile) { let (pool, tmp) = make_pool().await; let keypair = crypto::keys::load_or_generate(&pool) .await .expect("load_or_generate keypair"); let cfg = Config { bind: "127.0.0.1:0".parse().unwrap(), db_path: PathBuf::from(":memory:"), admin_api_key: TEST_ADMIN_KEY.to_string(), btcpay_url: "http://btcpay.test:23000".to_string(), btcpay_browser_url: None, btcpay_public_url: None, btcpay_api_key: None, btcpay_store_id: None, btcpay_webhook_secret: None, public_base_url: "http://keysat.test".to_string(), operator_name: Some("Test Operator".into()), }; let state = AppState { db: pool, keypair: Arc::new(keypair), payment: Arc::new(RwLock::new(None)), config: Arc::new(cfg), self_tier: Arc::new(RwLock::new(Tier::Unlicensed { reason: "test fixture".into(), })), }; (state, tmp) } /// Issue one request through the router. Clones state per call (cheap; /// the DB pool, Arc'd config and keypair are all `Clone`) so multiple /// requests in a single test share the same backend. async fn send(state: &AppState, req: Request) -> Response { api::router(state.clone()) .oneshot(req) .await .expect("router::oneshot") } async fn body_json(resp: Response) -> Value { let bytes = to_bytes(resp.into_body(), 1024 * 1024) .await .expect("read body"); serde_json::from_slice(&bytes).expect("response body should be JSON") } fn build_request( method: &str, uri: &str, headers: &[(&str, &str)], body: Option, ) -> Request { let mut b = Request::builder().method(method).uri(uri); for (k, v) in headers { b = b.header(*k, *v); } let body = match body { Some(v) => { b = b.header("content-type", "application/json"); Body::from(serde_json::to_vec(&v).expect("serialize JSON body")) } None => Body::empty(), }; b.body(body).expect("build request") } // --------------------------------------------------------------------- // Tests // --------------------------------------------------------------------- /// Smoke test for the framework. If this passes, we know the /// state-construction + router-dispatch + response-parsing pipeline /// works; tests below can focus on real assertions. #[tokio::test] async fn health_endpoint_returns_200() { let (state, _tmp) = make_test_state().await; let req = build_request("GET", "/healthz", &[], None); let resp = send(&state, req).await; assert_eq!(resp.status(), StatusCode::OK); } /// Admin endpoints reject calls that lack a valid admin token. The /// distinction between 401 (no/malformed header) and 403 (header present /// but token doesn't match) matters — the SPA renders different UI for /// each ("you're not logged in" vs "you don't have permission"). #[tokio::test] async fn admin_endpoint_rejects_missing_or_wrong_auth() { let (state, _tmp) = make_test_state().await; let body = json!({"slug": "x", "name": "X", "price_sats": 100}); // No Authorization header → 401 unauthorized. let req = build_request("POST", "/v1/admin/products", &[], Some(body.clone())); let resp = send(&state, req).await; assert_eq!( resp.status(), StatusCode::UNAUTHORIZED, "missing auth header should be 401" ); // Wrong token → 403 forbidden. (The constant-time compare in // require_admin returns Forbidden, not Unauthorized, when a token // is present but doesn't match.) let req = build_request( "POST", "/v1/admin/products", &[( "authorization", "Bearer wrong_token_xxxxxxxxxxxxxxxxxxxxxxxx", )], Some(body), ); let resp = send(&state, req).await; assert_eq!( resp.status(), StatusCode::FORBIDDEN, "wrong token should be 403" ); } /// The full happy path for an admin write: auth → handler → DB insert /// → audit log → response. If a refactor ever breaks one of those /// links, this fails loudly. #[tokio::test] async fn admin_creates_product_with_correct_token() { let (state, _tmp) = make_test_state().await; let auth = format!("Bearer {}", TEST_ADMIN_KEY); let req = build_request( "POST", "/v1/admin/products", &[("authorization", &auth)], Some(json!({ "slug": "test-product", "name": "Test Product", "description": "for tests", "price_sats": 10_000 })), ); let resp = send(&state, req).await; assert_eq!( resp.status(), StatusCode::OK, "expected 200; got {}", resp.status() ); let body = body_json(resp).await; assert_eq!(body["slug"], "test-product"); assert_eq!(body["name"], "Test Product"); assert_eq!(body["price_sats"], 10_000); let id = body["id"] .as_str() .expect("response body should contain product id") .to_string(); // Row landed in DB. let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products WHERE id = ?") .bind(&id) .fetch_one(&state.db) .await .unwrap(); assert_eq!(count, 1, "exactly one product row should exist"); // Audit row was written for the create. let audit_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM audit_log WHERE action = 'product.create' AND target_id = ?", ) .bind(&id) .fetch_one(&state.db) .await .unwrap(); assert_eq!(audit_count, 1, "audit log should record one create"); } /// `/v1/validate` always returns HTTP 200 (per the documented contract); /// failures are surfaced via `ok: false` + a machine-readable `reason`. /// Bogus input returns `bad_format` — the parser couldn't even decode /// the base32 envelope. This exercises the rate-limit pre-check and /// the early parse-fail path. #[tokio::test] async fn validate_rejects_unsigned_garbage() { let (state, _tmp) = make_test_state().await; let req = build_request( "POST", "/v1/validate", &[], Some(json!({"key": "not-a-real-license"})), ); let resp = send(&state, req).await; assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert_eq!(body["ok"], false); assert_eq!(body["reason"], "bad_format"); } /// End-to-end license validation: /// - seed a product /// - issue a license tied to it /// - sign a matching `LicensePayload` with the daemon's actual key /// - encode to the base32 wire format /// - POST /v1/validate /// - assert `ok: true` plus the populated metadata fields /// /// This is the most complex of the first round — it ties together DB /// writes, the crypto module, and the validate handler. If anything in /// any of those layers regresses, this fails. #[tokio::test] async fn validate_accepts_well_formed_license() { let (state, _tmp) = make_test_state().await; // Seed a product directly via the repo (skip the admin endpoint — // this test is about /v1/validate, not product creation). let product = repo::create_product( &state.db, "validate-test", "Validate Test", "", 100, &json!({}), ) .await .expect("create_product"); // Issue a license tied to that product. Perpetual, single-machine, // no entitlements — the simplest valid license shape. let license_id = Uuid::new_v4(); let issued_at = Utc::now(); repo::create_license( &state.db, &license_id.to_string(), &product.id, None, // invoice_id (manual issuance — no invoice) &issued_at.to_rfc3339(), &json!({}), // metadata None, // policy_id None, // expires_at — perpetual 0, // grace_seconds 1, // max_machines &[], // entitlements false, // is_trial None, // buyer_email None, // nostr_npub ) .await .expect("create_license"); // Build the matching signed payload. Must use the same product_id // and license_id as the DB row, because validate() looks the row up // by license_id and verifies product_id matches. let product_uuid = Uuid::parse_str(&product.id).expect("product id is a uuid"); let payload = LicensePayload { version: 2, flags: 0, product_id: product_uuid, license_id, issued_at: issued_at.timestamp(), expires_at: 0, fingerprint_hash: [0; 32], entitlements: vec![], }; let signature = crypto::sign_payload(&state.keypair.signing, &payload); let key_string = crypto::encode_key(&payload, &signature); let req = build_request( "POST", "/v1/validate", &[], Some(json!({"key": key_string})), ); let resp = send(&state, req).await; assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert_eq!( body["ok"], true, "validation rejected a known-good license: {body:?}" ); assert_eq!(body["license_id"], license_id.to_string()); assert_eq!(body["product_id"], product.id); assert_eq!(body["status"], "active"); }