diff --git a/licensing-service/Cargo.lock b/licensing-service/Cargo.lock index d38def9..54510c4 100644 --- a/licensing-service/Cargo.lock +++ b/licensing-service/Cargo.lock @@ -1299,6 +1299,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2424,6 +2444,10 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", "tower-layer", "tower-service", "tracing", diff --git a/licensing-service/Cargo.toml b/licensing-service/Cargo.toml index bc74d56..b553254 100644 --- a/licensing-service/Cargo.toml +++ b/licensing-service/Cargo.toml @@ -103,6 +103,11 @@ mime_guess = "2" # us NamedTempFile so the OS cleans up if a test panics mid-run. tempfile = "3" +# tower in main deps is feature-minimal (only what axum needs). Tests use +# `ServiceExt::oneshot` to drive the router without bringing up an HTTP +# listener — that lives behind the `util` feature. +tower = { version = "0.4", features = ["util"] } + [profile.release] opt-level = 3 lto = "thin" diff --git a/licensing-service/src/lib.rs b/licensing-service/src/lib.rs new file mode 100644 index 0000000..471b792 --- /dev/null +++ b/licensing-service/src/lib.rs @@ -0,0 +1,31 @@ +//! Keysat library — the daemon's internal modules, exposed as a library +//! so integration tests under `tests/` can drive the API directly without +//! re-implementing the bootstrap. +//! +//! The binary at `src/main.rs` is a thin wrapper that loads runtime config +//! from environment variables, starts the HTTP server, and spawns +//! background tasks. Tests bypass that wrapper and construct `AppState` +//! programmatically. + +pub mod api; +pub mod btcpay; +pub mod config; +pub mod crypto; +pub mod db; +pub mod error; +pub mod license_self; +pub mod models; +pub mod payment; +pub mod rate_limit; +pub mod reconcile; +pub mod tipping; +pub mod webhooks; + +/// Hex-encoded SHA-256 of a string — used everywhere we need a deterministic +/// id from a raw value (machine fingerprints, admin key hashes). +pub fn hex_sha256(s: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(s.as_bytes()); + hex::encode(hasher.finalize()) +} diff --git a/licensing-service/src/main.rs b/licensing-service/src/main.rs index 9110ee6..97c11be 100644 --- a/licensing-service/src/main.rs +++ b/licensing-service/src/main.rs @@ -1,29 +1,12 @@ //! Entry point. Wires config → logging → DB → keypair → HTTP server. - -mod api; -mod btcpay; -mod config; -mod crypto; -mod db; -mod error; -mod license_self; -mod models; -mod payment; -mod rate_limit; -mod reconcile; -mod tipping; -mod webhooks; - -/// Hex-encoded SHA-256 of a string — used everywhere we need a deterministic -/// id from a raw value (machine fingerprints, admin key hashes). -pub fn hex_sha256(s: &str) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(s.as_bytes()); - hex::encode(hasher.finalize()) -} +//! +//! The actual modules (api, btcpay, db, etc.) live in `src/lib.rs` so that +//! integration tests under `tests/` can also reach them. Both the binary +//! and the library compile from the same source files; nothing here +//! changes between targets. use anyhow::Context; +use keysat::{api, btcpay, config, crypto, db, license_self, payment, reconcile, webhooks}; use std::sync::Arc; use tower_http::trace::TraceLayer; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs new file mode 100644 index 0000000..fdc5093 --- /dev/null +++ b/licensing-service/tests/api.rs @@ -0,0 +1,364 @@ +//! 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