Files
keysat/licensing-service/src/main.rs
T
Grant 81066dfe62 Add API endpoint integration tests + library scaffolding
Closes the next-biggest test gap after migration tests. The daemon has
54+ HTTP endpoints, all previously untested at the request/response
level — same shape of blind spot that allowed the v0.1.0:39 migration
bug to ship.

What's new:

- src/lib.rs — exposes the daemon's modules as a library so integration
  tests can import them (`pub mod api;`, etc.). Module source files are
  unchanged; main.rs now imports via `use keysat::...` instead of
  declaring `mod api;` directly. No runtime behaviour change in the
  binary.

- tests/api.rs — 5 integration tests that drive real HTTP requests
  through axum::Router::oneshot against a real SQLite tempfile pool
  (same options as src/db/mod.rs::init):
    1. health_endpoint_returns_200 — framework smoke test
    2. admin_endpoint_rejects_missing_or_wrong_auth — 401 vs 403 paths
    3. admin_creates_product_with_correct_token — full happy path
       (auth → handler → DB insert → audit log → response)
    4. validate_rejects_unsigned_garbage — early parse-fail surfaces
       as `ok: false, reason: "bad_format"` (HTTP still 200)
    5. validate_accepts_well_formed_license — issues a license via
       repo, signs a matching LicensePayload with the daemon's
       actual key, encodes to wire format, validates via the
       endpoint, asserts ok=true plus populated metadata fields

Test count: 9 unit + 4 migrations + 5 API = 18 (was 13).

Cargo.toml dev-deps gain `tower = { version = "0.4", features = ["util"] }`
for ServiceExt::oneshot. The main `tower` dep is feature-minimal because
axum only needs a subset.

Out of scope (explicit follow-ups):

- Purchase happy path (needs a MockPaymentProvider implementing the
  trait; ~250 LOC of mock + ~200 LOC of test).
- Webhook handler with idempotency assertions (same MockPaymentProvider
  dependency).
- Tier-cap enforcement (mechanically simple; small follow-up PR).
- Discount-code atomic reserve race (better as a SQL-layer unit test
  than an HTTP integration test).
- Rate-limiting (interacts with shared state; needs careful isolation).
- Cookie/session auth (already covered in session_layer.rs).
2026-05-08 09:14:27 -05:00

190 lines
6.6 KiB
Rust

//! Entry point. Wires config → logging → DB → keypair → HTTP server.
//!
//! 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};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// --- logging ---
tracing_subscriber::registry()
.with(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn")),
)
.with(fmt::layer().with_target(false))
.init();
// --- config ---
let cfg = config::Config::from_env().context("loading configuration")?;
tracing::info!(
bind = %cfg.bind,
db = %cfg.db_path.display(),
btcpay_url = %cfg.btcpay_url,
btcpay_browser_url = ?cfg.btcpay_browser_url,
btcpay_public_url = ?cfg.btcpay_public_url,
"starting keysat v{}",
env!("CARGO_PKG_VERSION")
);
// --- self-license tier (Keysat-licenses-Keysat) ---
// Verifies any /data/keysat-license.txt against the embedded master
// pubkey. In permissive builds (default) a missing/invalid license
// logs a warning and we continue. In enforce builds (compiled with
// KEYSAT_LICENSE_ENFORCE=1) a missing/invalid license refuses to
// start. Result is held in app state so the admin UI can surface it.
let self_tier = Arc::new(tokio::sync::RwLock::new(
license_self::check_at_boot()
.context("Keysat self-license check failed (enforce mode)")?,
));
// --- database ---
let pool = db::init(&cfg.db_path).await?;
// --- signing key ---
let keypair = crypto::keys::load_or_generate(&pool).await?;
tracing::info!(
"signing key ready; public key:\n{}",
keypair.public_key_pem.trim()
);
// --- payment provider (may be None until operator connects) ---
let provider: Option<Arc<dyn payment::PaymentProvider>> =
load_btcpay_provider(&pool, &cfg).await.map(|p| {
let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p);
arc
});
match &provider {
Some(p) => tracing::info!(provider = p.kind().as_str(), "payment provider connected"),
None => tracing::warn!(
"no payment provider yet configured — purchases will return 503 until the \
operator completes the 'Connect BTCPay' flow"
),
}
let state = api::AppState {
db: pool,
keypair: Arc::new(keypair),
payment: Arc::new(tokio::sync::RwLock::new(provider)),
config: Arc::new(cfg.clone()),
self_tier,
};
// Spawn background loops before handing state to the router.
reconcile::spawn(state.clone());
webhooks::spawn_delivery_worker(state.clone());
// Hourly session reaper — drops sessions whose expires_at < now.
{
let pool = state.db.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
match db::repo::reap_expired_sessions(&pool).await {
Ok(n) if n > 0 => tracing::info!("reaped {n} expired session(s)"),
Ok(_) => {}
Err(e) => tracing::warn!("session reaper: {e}"),
}
}
});
}
let app = api::router(state).layer(TraceLayer::new_for_http());
// --- serve ---
let listener = tokio::net::TcpListener::bind(cfg.bind)
.await
.with_context(|| format!("binding to {}", cfg.bind))?;
tracing::info!("listening on http://{}", cfg.bind);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
tracing::info!("shutdown complete");
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received");
}
/// Load a BtcpayProvider from (in order): DB, then env var seed, then None.
/// Never fails — an unconfigured service simply returns 503 on purchase paths
/// until the operator completes the connect flow. Returns the concrete
/// `BtcpayProvider` so the caller can decide how to wrap it (we wrap as
/// `Arc<dyn PaymentProvider>` in `main`).
async fn load_btcpay_provider(
pool: &sqlx::SqlitePool,
cfg: &config::Config,
) -> Option<payment::btcpay::BtcpayProvider> {
// DB first.
if let Ok(Some(saved)) = btcpay::config::load(pool).await {
let client = btcpay::client::BtcpayClient::new(
&saved.base_url,
&saved.api_key,
&saved.store_id,
);
return Some(
payment::btcpay::BtcpayProvider::new(client, saved.webhook_secret)
.with_public_base(cfg.btcpay_public_url.clone()),
);
}
// Fall back to env seed (useful for dev / legacy installs).
if let (Some(api_key), Some(store_id), Some(secret)) = (
cfg.btcpay_api_key.as_deref(),
cfg.btcpay_store_id.as_deref(),
cfg.btcpay_webhook_secret.as_deref(),
) {
let client =
btcpay::client::BtcpayClient::new(&cfg.btcpay_url, api_key, store_id);
// Persist the seed into DB so it survives env changes.
let _ = btcpay::config::save(
pool,
&btcpay::config::BtcpayConfig {
base_url: cfg.btcpay_url.clone(),
api_key: api_key.to_string(),
store_id: store_id.to_string(),
webhook_id: None,
webhook_secret: secret.to_string(),
},
)
.await;
return Some(
payment::btcpay::BtcpayProvider::new(client, secret.to_string())
.with_public_base(cfg.btcpay_public_url.clone()),
);
}
None
}