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).
This commit is contained in:
Generated
+24
@@ -1299,6 +1299,26 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
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]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2424,6 +2444,10 @@ version = "0.4.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project",
|
||||||
|
"pin-project-lite",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ mime_guess = "2"
|
|||||||
# us NamedTempFile so the OS cleans up if a test panics mid-run.
|
# us NamedTempFile so the OS cleans up if a test panics mid-run.
|
||||||
tempfile = "3"
|
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]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -1,29 +1,12 @@
|
|||||||
//! Entry point. Wires config → logging → DB → keypair → HTTP server.
|
//! Entry point. Wires config → logging → DB → keypair → HTTP server.
|
||||||
|
//!
|
||||||
mod api;
|
//! The actual modules (api, btcpay, db, etc.) live in `src/lib.rs` so that
|
||||||
mod btcpay;
|
//! integration tests under `tests/` can also reach them. Both the binary
|
||||||
mod config;
|
//! and the library compile from the same source files; nothing here
|
||||||
mod crypto;
|
//! changes between targets.
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use keysat::{api, btcpay, config, crypto, db, license_self, payment, reconcile, webhooks};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||||
|
|||||||
@@ -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<Body>) -> 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<Value>,
|
||||||
|
) -> Request<Body> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user