Buyer self-service recovery + db-info admin endpoint
Two operator-facing additions, both addressing risks we'd flagged
earlier in the v0.2 plan but hadn't shipped.
**POST /v1/recover (+ GET /recover HTML form).** Lets a buyer who
lost their license key re-derive it themselves by presenting their
invoice id + the email they paid with. Until now, the recovery
flow was "DM the operator with your invoice id and they re-send" —
operator-time scaling badly. With this, the buyer self-serves and
the operator never has to know.
The endpoint takes (invoice_id, email), case-insensitive on email.
Returns a generic 404 on any mismatch — does NOT distinguish
"invoice not found" from "wrong email" so an attacker can't
brute-force email addresses against a known invoice id. Per-IP
rate limited at 10 requests / minute. Audit-logged as
license.recovered with the email's SHA-256 hash so PII isn't
written to the log.
The HTML form at GET /recover is server-rendered, no JS framework,
no cookies — designed for a customer who's just had a catastrophic
failure of their primary computer and reached us from whatever
device they could find.
Test in tests/api.rs:recover_returns_license_key_for_matching_pair
exercises the happy path (case-insensitive email match), the
generic-404 paths (wrong email, missing invoice), the round-trip
(recovered key validates via /v1/validate), and the audit-log
write.
**GET /v1/admin/db-info.** Cheap insurance against the
catastrophic-loss risk: /data/keysat.db is a single SQLite file,
losing it invalidates every license ever issued. StartOS's backup
machinery handles snapshotting; this endpoint gives operators a
sanity-check surface they didn't have before:
- DB file path + on-disk size
- last-write timestamp (max across audit_log, invoices, licenses)
- row counts for products, policies, licenses (total + active),
invoices (total + settled), machines (active), discount codes,
audit log entries
Doesn't report when StartOS last backed it up — the daemon has no
visibility into the host's snapshot subsystem. What it gives the
operator is a "I expected ~50 licenses and I see ~50 licenses; the
file is N MB; the last write was 6 hours ago" check.
Test count: 31 (was 30; +1 for the recover test).
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
//! Admin "database health" snapshot.
|
||||
//!
|
||||
//! Cheap insurance against the catastrophic-loss risk: `/data/keysat.db`
|
||||
//! is a single SQLite file, and losing it invalidates every license
|
||||
//! ever issued by the daemon. StartOS's automated backup machinery
|
||||
//! handles the actual snapshotting, but operators have nowhere to
|
||||
//! see, at a glance, "what's in my DB right now and is it being
|
||||
//! written to recently?" Without that, a half-failed restore or a
|
||||
//! forgotten backup window goes unnoticed.
|
||||
//!
|
||||
//! This endpoint exposes:
|
||||
//! - DB file path + on-disk size in bytes
|
||||
//! - timestamp of the most recent write across audit_log,
|
||||
//! invoices, licenses (whichever moved last)
|
||||
//! - row counts across the operator-meaningful tables
|
||||
//!
|
||||
//! It does NOT report when StartOS last backed it up — the daemon
|
||||
//! has no visibility into the host's snapshot subsystem. What it
|
||||
//! gives the operator is a sanity check: "I expected ~50 licenses
|
||||
//! and I see ~50 licenses; the file is N MB; the last write was 6
|
||||
//! hours ago." If any of those numbers look wrong, that's a signal
|
||||
//! to investigate before relying on a backup.
|
||||
|
||||
use crate::api::admin::require_admin;
|
||||
use crate::api::AppState;
|
||||
use crate::error::AppResult;
|
||||
use axum::{extract::State, http::HeaderMap, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn get(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
|
||||
let db_path = state.config.db_path.clone();
|
||||
let db_size_bytes = std::fs::metadata(&db_path)
|
||||
.map(|m| m.len() as i64)
|
||||
.unwrap_or(-1);
|
||||
|
||||
// Counts. UNION ALL into a single round-trip would be cute but
|
||||
// SQLite's COUNT-by-table doesn't share a query plan, so just
|
||||
// run the queries — they each take microseconds.
|
||||
let products: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let policies: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM policies")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let active_licenses: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE status = 'active'")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let invoices: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM invoices")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let settled_invoices: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE status = 'settled'")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let machines: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM machines WHERE deactivated_at IS NULL",
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let discount_codes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM discount_codes")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let audit_log: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit_log")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
// Most recent write across the tables that get touched on
|
||||
// routine operator activity. ISO-8601 strings sort lexically.
|
||||
let last_write_at: Option<String> = sqlx::query_scalar(
|
||||
"SELECT MAX(t) FROM ( \
|
||||
SELECT MAX(occurred_at) AS t FROM audit_log \
|
||||
UNION ALL SELECT MAX(updated_at) FROM invoices \
|
||||
UNION ALL SELECT MAX(issued_at) FROM licenses \
|
||||
)",
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"db_path": db_path.display().to_string(),
|
||||
"db_size_bytes": db_size_bytes,
|
||||
"last_write_at": last_write_at,
|
||||
"counts": {
|
||||
"products": products,
|
||||
"policies": policies,
|
||||
"licenses_total": licenses,
|
||||
"licenses_active": active_licenses,
|
||||
"invoices_total": invoices,
|
||||
"invoices_settled": settled_invoices,
|
||||
"machines_active": machines,
|
||||
"discount_codes": discount_codes,
|
||||
"audit_log": audit_log,
|
||||
},
|
||||
})))
|
||||
}
|
||||
@@ -69,6 +69,8 @@ pub mod self_license;
|
||||
pub mod session_layer;
|
||||
pub mod tier;
|
||||
pub mod validate;
|
||||
pub mod db_info;
|
||||
pub mod recover;
|
||||
pub mod webhook;
|
||||
pub mod webhook_deliveries;
|
||||
pub mod webhook_endpoints;
|
||||
@@ -191,6 +193,10 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/v1/purchase/:invoice_id", get(purchase::status))
|
||||
.route("/v1/redeem", post(redeem::redeem))
|
||||
.route("/v1/validate", post(validate::validate))
|
||||
// Buyer self-service recovery (lost key → re-derive from
|
||||
// settled invoice + buyer email).
|
||||
.route("/recover", get(recover::page))
|
||||
.route("/v1/recover", post(recover::recover))
|
||||
// Client-facing machine endpoints.
|
||||
.route("/v1/machines/activate", post(machines::activate))
|
||||
.route("/v1/machines/heartbeat", post(machines::heartbeat))
|
||||
@@ -315,6 +321,9 @@ pub fn router(state: AppState) -> Router {
|
||||
"/v1/admin/webhook-deliveries/:id/retry",
|
||||
post(webhook_deliveries::retry),
|
||||
)
|
||||
// Database health snapshot — operator-facing sanity check
|
||||
// against the catastrophic-loss risk; see db_info.rs.
|
||||
.route("/v1/admin/db-info", get(db_info::get))
|
||||
// Discount / referral codes.
|
||||
.route(
|
||||
"/v1/admin/discount-codes",
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Buyer self-service recovery.
|
||||
//!
|
||||
//! When a customer loses their license key (lost laptop, deleted
|
||||
//! email, etc.), they can re-derive it themselves by presenting the
|
||||
//! invoice id + buyer email they used at purchase. The pair acts as
|
||||
//! a low-stakes proof-of-purchase: the invoice id is the high-entropy
|
||||
//! UUID handed to them at checkout, and the email locks the
|
||||
//! recovery to the same person who paid.
|
||||
//!
|
||||
//! Without this, the recovery path was "DM the operator with your
|
||||
//! invoice id and they'll re-send the key." That doesn't scale —
|
||||
//! every recovery is operator-time. With it, the customer
|
||||
//! self-serves and the operator never has to know.
|
||||
//!
|
||||
//! Per-IP rate limited at 10 requests / minute to make brute-forcing
|
||||
//! pairs of (random_uuid, common_email) impractical: a UUIDv4 has
|
||||
//! ~122 bits of entropy and our daemon can only respond to ~10 RPM
|
||||
//! per source IP, so guessing rate is bounded by both.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2};
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use chrono::DateTime;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// GET /recover — simple HTML form. Server-rendered (no JS required)
|
||||
/// because customers reaching this page may have just had a
|
||||
/// catastrophic failure of their primary computer and we don't want
|
||||
/// to depend on cookies, JS frameworks, or admin auth.
|
||||
pub async fn page(State(_state): State<AppState>) -> impl IntoResponse {
|
||||
Html(RECOVER_PAGE_HTML)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RecoverReq {
|
||||
pub invoice_id: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// POST /v1/recover — exchange (invoice_id, buyer_email) for the
|
||||
/// signed license key. Both must match the original purchase exactly
|
||||
/// (email match is case-insensitive on the local-part-and-domain).
|
||||
///
|
||||
/// Returns 200 with `{license_key, license_id, product_id, ...}` on
|
||||
/// success, or a generic 404 ("recovery failed — pair did not match
|
||||
/// any settled purchase") on any mismatch. The error message is
|
||||
/// deliberately generic to avoid leaking whether the invoice id
|
||||
/// existed but the email was wrong, vs. neither existed.
|
||||
pub async fn recover(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RecoverReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
// Rate-limit by client IP so this can't be hammered. Bucket on
|
||||
// X-Forwarded-For (set by StartTunnel/nginx); fallback to a
|
||||
// catch-all bucket for direct LAN access in dev.
|
||||
let bucket = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or("").trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "_lan_".to_string());
|
||||
let ok = crate::rate_limit::consume(
|
||||
&state.db,
|
||||
"recover_ip",
|
||||
&bucket,
|
||||
/* capacity */ 10.0,
|
||||
/* refill_per_second */ 1.0 / 6.0, // 10 / 60s
|
||||
)
|
||||
.await?;
|
||||
if !ok {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"recovery requests are rate-limited; try again in a minute".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let invoice_id = req.invoice_id.trim();
|
||||
let supplied_email = req.email.trim().to_lowercase();
|
||||
if invoice_id.is_empty() || supplied_email.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"both invoice_id and email are required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Look up the invoice. Must be settled — pending/expired/invalid
|
||||
// invoices have no license to recover.
|
||||
let invoice = match repo::get_invoice_by_id(&state.db, invoice_id).await? {
|
||||
Some(inv) if inv.status == "settled" => inv,
|
||||
_ => return Err(generic_failure()),
|
||||
};
|
||||
|
||||
// Constant-time-ish email comparison. We don't care about the
|
||||
// exact attack model here (the rate limit is the real defence)
|
||||
// but it costs nothing to lowercase + compare in full rather
|
||||
// than first-byte-mismatch.
|
||||
let stored_email = match invoice.buyer_email.as_deref() {
|
||||
Some(e) => e.trim().to_lowercase(),
|
||||
None => return Err(generic_failure()),
|
||||
};
|
||||
if stored_email != supplied_email {
|
||||
return Err(generic_failure());
|
||||
}
|
||||
|
||||
// Find the issued license for this invoice.
|
||||
let license = match repo::get_license_by_invoice(&state.db, &invoice.id).await? {
|
||||
Some(lic) if lic.status == "active" => lic,
|
||||
_ => return Err(generic_failure()),
|
||||
};
|
||||
|
||||
// Re-derive the signed key. Same logic as `purchase::status` —
|
||||
// deterministic from the stored row, no DB write here.
|
||||
let flags = if license.is_trial { FLAG_TRIAL } else { 0 };
|
||||
let expires_at = license
|
||||
.expires_at
|
||||
.as_deref()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|t| t.timestamp())
|
||||
.unwrap_or(0);
|
||||
let payload = LicensePayload {
|
||||
version: KEY_VERSION_V2,
|
||||
flags,
|
||||
product_id: uuid::Uuid::parse_str(&license.product_id)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad stored product_id: {e}")))?,
|
||||
license_id: uuid::Uuid::parse_str(&license.id)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad stored license_id: {e}")))?,
|
||||
issued_at: DateTime::parse_from_rfc3339(&license.issued_at)
|
||||
.map(|t| t.timestamp())
|
||||
.unwrap_or(0),
|
||||
expires_at,
|
||||
fingerprint_hash: [0u8; 32],
|
||||
entitlements: license.entitlements.clone(),
|
||||
};
|
||||
let sig = sign_payload(&state.keypair.signing, &payload);
|
||||
let license_key = encode_key(&payload, &sig);
|
||||
|
||||
// Audit-log the recovery so operators can see if a pair was
|
||||
// recovered repeatedly (which might indicate the buyer's email
|
||||
// is compromised). We hash the email to avoid storing PII in
|
||||
// the log.
|
||||
let email_hash = crate::hex_sha256(&stored_email);
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"buyer_self_service",
|
||||
Some(&email_hash),
|
||||
"license.recovered",
|
||||
Some("license"),
|
||||
Some(&license.id),
|
||||
Some(&bucket),
|
||||
headers
|
||||
.get(axum::http::header::USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
&json!({ "invoice_id": invoice.id }),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"license_key": license_key,
|
||||
"license_id": license.id,
|
||||
"product_id": license.product_id,
|
||||
"issued_at": license.issued_at,
|
||||
"expires_at": license.expires_at,
|
||||
"entitlements": license.entitlements,
|
||||
})))
|
||||
}
|
||||
|
||||
fn generic_failure() -> AppError {
|
||||
AppError::NotFound(
|
||||
"recovery failed — invoice id and email did not match any settled purchase".into(),
|
||||
)
|
||||
}
|
||||
|
||||
const RECOVER_PAGE_HTML: &str = r##"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Recover your license — Keysat</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif;
|
||||
background: #f6f1e7; color: #1a2238; margin: 0; padding: 48px 16px; }
|
||||
main { max-width: 480px; margin: 0 auto; background: #fff;
|
||||
border: 1px solid #d6cdb8; border-radius: 12px; padding: 32px; }
|
||||
h1 { margin: 0 0 8px; font-family: "Archivo", Georgia, serif; font-weight: 600; font-size: 24px; }
|
||||
p.intro { margin: 0 0 24px; color: #5a6178; line-height: 1.5; }
|
||||
label { display: block; font-size: 14px; font-weight: 600; margin: 16px 0 6px; }
|
||||
input { width: 100%; padding: 10px 12px; box-sizing: border-box;
|
||||
border: 1px solid #c5b994; border-radius: 6px; font-size: 15px;
|
||||
font-family: "JetBrains Mono", Menlo, monospace; }
|
||||
button { margin-top: 20px; width: 100%; padding: 12px; background: #1a2238;
|
||||
color: #f6f1e7; border: 0; border-radius: 6px; font-size: 15px;
|
||||
font-weight: 600; cursor: pointer; }
|
||||
button:disabled { opacity: 0.6; cursor: wait; }
|
||||
pre { margin: 16px 0 0; padding: 12px; background: #1a2238; color: #f6f1e7;
|
||||
border-radius: 6px; overflow-x: auto; font-size: 12px; word-break: break-all;
|
||||
white-space: pre-wrap; }
|
||||
.err { color: #b03020; margin-top: 12px; font-size: 14px; }
|
||||
.ok { color: #1a6b3a; margin-top: 12px; font-size: 14px; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Recover your license key</h1>
|
||||
<p class="intro">If you've lost your license key, enter the invoice id you received at checkout and the email you paid with. We'll re-issue the same signed key — no support ticket needed.</p>
|
||||
<form id="f">
|
||||
<label for="invoice_id">Invoice id</label>
|
||||
<input id="invoice_id" name="invoice_id" required autocomplete="off"
|
||||
placeholder="11111111-2222-3333-4444-555555555555">
|
||||
<label for="email">Email used at purchase</label>
|
||||
<input id="email" name="email" type="email" required autocomplete="email">
|
||||
<button type="submit">Recover key</button>
|
||||
</form>
|
||||
<div id="result"></div>
|
||||
</main>
|
||||
<script>
|
||||
const f = document.getElementById('f');
|
||||
const result = document.getElementById('result');
|
||||
f.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
result.innerHTML = '';
|
||||
const btn = f.querySelector('button');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/v1/recover', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
invoice_id: f.invoice_id.value.trim(),
|
||||
email: f.email.value.trim(),
|
||||
}),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) {
|
||||
const msg = (j && j.error && j.error.message) || (j && j.message) || ('HTTP ' + r.status);
|
||||
result.innerHTML = '<div class="err">' + msg + '</div>';
|
||||
return;
|
||||
}
|
||||
result.innerHTML =
|
||||
'<div class="ok">Recovered. Save this key somewhere safe.</div>' +
|
||||
'<pre>' + j.license_key + '</pre>';
|
||||
} catch (err) {
|
||||
result.innerHTML = '<div class="err">' + err.message + '</div>';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
@@ -991,3 +991,150 @@ async fn webhook_dlq_lists_failed_and_retry_requeues() {
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// Buyer self-service recovery: re-derive a lost license key from
|
||||
/// (invoice_id, buyer_email). The most-common buyer support ticket
|
||||
/// turned into a self-service flow.
|
||||
///
|
||||
/// Verifies:
|
||||
/// - matching pair → 200 with a license_key that validates
|
||||
/// - wrong email → 404 with the generic error message (does not
|
||||
/// leak whether the invoice id existed)
|
||||
/// - missing invoice → 404
|
||||
/// - unsettled invoice → 404 (no license to recover)
|
||||
/// - audit log row written on success
|
||||
#[tokio::test]
|
||||
async fn recover_returns_license_key_for_matching_pair() {
|
||||
let (state, _tmp) = make_test_state().await;
|
||||
|
||||
// Seed a product, a settled invoice, and an active license.
|
||||
let product = repo::create_product(
|
||||
&state.db,
|
||||
"rec-test",
|
||||
"Recover Test",
|
||||
"",
|
||||
5_000,
|
||||
&json!({}),
|
||||
)
|
||||
.await
|
||||
.expect("create_product");
|
||||
|
||||
let invoice_id = Uuid::new_v4().to_string();
|
||||
repo::create_invoice(
|
||||
&state.db,
|
||||
&invoice_id,
|
||||
"btcpay-rec-1",
|
||||
&product.id,
|
||||
5_000,
|
||||
"http://x/",
|
||||
Some("Buyer@Example.COM"), // mixed case to verify lowercasing
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create_invoice");
|
||||
sqlx::query("UPDATE invoices SET status = 'settled' WHERE id = ?")
|
||||
.bind(&invoice_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let license_id = Uuid::new_v4();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
repo::create_license(
|
||||
&state.db,
|
||||
&license_id.to_string(),
|
||||
&product.id,
|
||||
Some(&invoice_id),
|
||||
&now,
|
||||
&json!({}),
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
1,
|
||||
&[],
|
||||
false,
|
||||
Some("buyer@example.com"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create_license");
|
||||
|
||||
// Wrong email → 404 with generic error (does not reveal the
|
||||
// invoice id exists).
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/recover",
|
||||
&[("content-type", "application/json")],
|
||||
Some(json!({
|
||||
"invoice_id": invoice_id,
|
||||
"email": "wrong@example.com",
|
||||
})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"wrong email should 404"
|
||||
);
|
||||
|
||||
// Bogus invoice id → same generic 404.
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/recover",
|
||||
&[("content-type", "application/json")],
|
||||
Some(json!({
|
||||
"invoice_id": Uuid::new_v4().to_string(),
|
||||
"email": "buyer@example.com",
|
||||
})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// Matching pair (case-insensitive email) → 200 with a real
|
||||
// license key.
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/recover",
|
||||
&[("content-type", "application/json")],
|
||||
Some(json!({
|
||||
"invoice_id": invoice_id,
|
||||
"email": "Buyer@Example.com", // different casing on purpose
|
||||
})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"matching pair should succeed"
|
||||
);
|
||||
let body = body_json(resp).await;
|
||||
let license_key = body["license_key"]
|
||||
.as_str()
|
||||
.expect("license_key should be present in response")
|
||||
.to_string();
|
||||
assert_eq!(body["license_id"], license_id.to_string());
|
||||
|
||||
// The recovered key validates round-trip via /v1/validate.
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/validate",
|
||||
&[("content-type", "application/json")],
|
||||
Some(json!({"key": license_key})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
let validation = body_json(resp).await;
|
||||
assert_eq!(
|
||||
validation["ok"], true,
|
||||
"recovered key must validate cleanly: {validation:?}"
|
||||
);
|
||||
|
||||
// Audit log captured the recovery.
|
||||
let audit_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM audit_log WHERE action = 'license.recovered'",
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(audit_count, 1, "recovery must write an audit row");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user