81066dfe62
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).
190 lines
6.6 KiB
Rust
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
|
|
}
|