feat(server): build axum web server for licensing, landing page, and updates
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::Router;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use tower::ServiceExt;
|
||||
use wiremock::matchers::{any, method, path_regex};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use clean_copy_server::crypto;
|
||||
use clean_copy_server::handlers::activate;
|
||||
use clean_copy_server::AppState;
|
||||
|
||||
fn payment_intent_json(id: &str, status: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"object": "payment_intent",
|
||||
"status": status,
|
||||
"amount": 2900,
|
||||
"amount_capturable": 0,
|
||||
"amount_received": 0,
|
||||
"currency": "usd",
|
||||
"capture_method": "automatic",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1700000000,
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"payment_method_types": ["card"],
|
||||
})
|
||||
}
|
||||
|
||||
async fn setup_app(mock_url: &str) -> (Router, SigningKey) {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::migrate!().run(&pool).await.unwrap();
|
||||
|
||||
let key = SigningKey::from_bytes(&[42u8; 32]);
|
||||
let stripe_client = stripe::Client::from_url(mock_url, "sk_test_fake");
|
||||
|
||||
let state = AppState {
|
||||
config: clean_copy_server::config::Config {
|
||||
stripe_secret_key: "sk_test_fake".to_string(),
|
||||
stripe_price_id: String::new(),
|
||||
signing_key_hex: hex::encode(key.to_bytes()),
|
||||
database_url: "sqlite::memory:".to_string(),
|
||||
release_dir: "/tmp/releases".to_string(),
|
||||
static_dir: "./static".to_string(),
|
||||
port: 3000,
|
||||
app_url: "http://localhost:1420".to_string(),
|
||||
},
|
||||
db: pool,
|
||||
signing_key: key.clone(),
|
||||
stripe_client,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/activate", post(activate::activate))
|
||||
.with_state(state);
|
||||
|
||||
(app, key)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_activate_new_license_with_mock_stripe() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
// Match ANY request to see what the Stripe client actually sends
|
||||
Mock::given(any())
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(payment_intent_json("pi_test_123", "succeeded")),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let (app, key) = setup_app(&mock_server.uri()).await;
|
||||
|
||||
let req_body = serde_json::json!({
|
||||
"license_key": "pi_test_123",
|
||||
"hwid": "hwid_device_1"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/activate")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_string(&req_body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body: serde_json::Value = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.map(|b| serde_json::from_slice(&b).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let token = body["license_token"].as_str().unwrap();
|
||||
assert!(!token.is_empty());
|
||||
|
||||
let pub_key = key.verifying_key().to_bytes();
|
||||
let payload = crypto::verify_license(token, &pub_key, "hwid_device_1").unwrap();
|
||||
assert_eq!(payload.license_key, "pi_test_123");
|
||||
assert_eq!(payload.device_count, Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_activate_payment_not_succeeded() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(any())
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(payment_intent_json("pi_pending", "requires_payment_method")),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let (app, _key) = setup_app(&mock_server.uri()).await;
|
||||
|
||||
let req_body = serde_json::json!({
|
||||
"license_key": "pi_pending",
|
||||
"hwid": "hwid_device_1"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/activate")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_string(&req_body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let body: serde_json::Value = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.map(|b| serde_json::from_slice(&b).unwrap())
|
||||
.unwrap();
|
||||
|
||||
assert!(body["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Payment has not been completed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_activate_payment_not_found() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/v1/payment_intents/pi_nonexistent"))
|
||||
.respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": "No such PaymentIntent: 'pi_nonexistent'",
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let (app, _key) = setup_app(&mock_server.uri()).await;
|
||||
|
||||
let req_body = serde_json::json!({
|
||||
"license_key": "pi_nonexistent",
|
||||
"hwid": "hwid_device_1"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/activate")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_string(&req_body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let body: serde_json::Value = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.map(|b| serde_json::from_slice(&b).unwrap())
|
||||
.unwrap();
|
||||
|
||||
assert!(body["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Payment not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_activate_missing_fields() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let (app, _key) = setup_app(&mock_server.uri()).await;
|
||||
|
||||
let req_body = serde_json::json!({
|
||||
"license_key": "",
|
||||
"hwid": "hwid_device_1"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/activate")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_string(&req_body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_activate_idempotent_same_device() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(any())
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(payment_intent_json("pi_test_123", "succeeded")),
|
||||
)
|
||||
.expect(2)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let (app, key) = setup_app(&mock_server.uri()).await;
|
||||
|
||||
let req_body = serde_json::json!({
|
||||
"license_key": "pi_test_123",
|
||||
"hwid": "hwid_device_1"
|
||||
});
|
||||
|
||||
let response1 = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/activate")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_string(&req_body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response1.status(), StatusCode::OK);
|
||||
|
||||
let response2 = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/activate")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_string(&req_body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response2.status(), StatusCode::OK);
|
||||
|
||||
let body2: serde_json::Value = axum::body::to_bytes(response2.into_body(), usize::MAX)
|
||||
.await
|
||||
.map(|b| serde_json::from_slice(&b).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let pub_key = key.verifying_key().to_bytes();
|
||||
let payload = crypto::verify_license(
|
||||
body2["license_token"].as_str().unwrap(),
|
||||
&pub_key,
|
||||
"hwid_device_1",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(payload.device_count, Some(1));
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use tower::ServiceExt;
|
||||
use wiremock::matchers::{method, path_regex};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use clean_copy_server::handlers::{activate, checkout};
|
||||
use clean_copy_server::AppState;
|
||||
|
||||
async fn setup_app(mock_url: &str) -> Router {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::migrate!().run(&pool).await.unwrap();
|
||||
|
||||
let key = SigningKey::from_bytes(&[42u8; 32]);
|
||||
let stripe_client = stripe::Client::from_url(mock_url, "sk_test_fake");
|
||||
|
||||
let state = AppState {
|
||||
config: clean_copy_server::config::Config {
|
||||
stripe_secret_key: "sk_test_fake".to_string(),
|
||||
stripe_price_id: "price_test_123".to_string(),
|
||||
signing_key_hex: hex::encode(key.to_bytes()),
|
||||
database_url: "sqlite::memory:".to_string(),
|
||||
release_dir: "/tmp/releases".to_string(),
|
||||
static_dir: "./static".to_string(),
|
||||
port: 3000,
|
||||
app_url: "http://localhost:3000".to_string(),
|
||||
},
|
||||
db: pool,
|
||||
signing_key: key,
|
||||
stripe_client,
|
||||
};
|
||||
|
||||
Router::new()
|
||||
.route("/api/checkout", get(checkout::checkout))
|
||||
.route("/api/activate", axum::routing::post(activate::activate))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn checkout_session_json(id: &str, url: Option<&str>) -> serde_json::Value {
|
||||
let mut json = serde_json::json!({
|
||||
"id": id,
|
||||
"object": "checkout.session",
|
||||
"mode": "payment",
|
||||
"automatic_tax": { "enabled": false },
|
||||
"created": 1700000000,
|
||||
"expires_at": 1700003600,
|
||||
"custom_fields": [],
|
||||
"custom_text": {
|
||||
"after_submit": null,
|
||||
"shipping_address": null,
|
||||
"submit": null,
|
||||
"terms_of_service_acceptance": null
|
||||
},
|
||||
"livemode": false,
|
||||
"payment_method_types": ["card"],
|
||||
"payment_status": "unpaid",
|
||||
"shipping_options": [],
|
||||
});
|
||||
if let Some(u) = url {
|
||||
json["url"] = serde_json::json!(u);
|
||||
}
|
||||
json
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_checkout_redirects_to_stripe() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/v1/checkout/sessions"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(checkout_session_json(
|
||||
"cs_test_123",
|
||||
Some("https://checkout.stripe.com/pay/cs_test_123"),
|
||||
)),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let app = setup_app(&mock_server.uri()).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/checkout")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
|
||||
let location = response
|
||||
.headers()
|
||||
.get("location")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert_eq!(location, "https://checkout.stripe.com/pay/cs_test_123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_checkout_stripe_error() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/v1/checkout/sessions"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": "No such price: 'price_bad'",
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let app = setup_app(&mock_server.uri()).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/checkout")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_checkout_no_url_in_response() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/v1/checkout/sessions"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(checkout_session_json("cs_test_no_url", None)),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let app = setup_app(&mock_server.uri()).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/checkout")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
Reference in New Issue
Block a user