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, +) -> 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"); +}