//! License key workflow: create item -> enable keys -> generate key -> //! validate -> deactivate -> check status use crate::harness::TestHarness; use serde_json::Value; #[tokio::test] async fn license_key_lifecycle() { let mut h = TestHarness::new().await; // Setup: creator with project and item let user_id = h .signup("keymaker", "keymaker@example.com", "password123") .await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("keymaker", "password123").await; let resp = h .client .post_form("/api/projects", "slug=software&title=Software") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), "title=My+Plugin&price_cents=0&item_type=plugin", ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap(); // Enable license keys with max 3 activations let resp = h .client .put_form( &format!("/api/items/{item_id}/license-settings"), "enable_license_keys=on&default_max_activations=3", ) .await; assert_eq!( resp.status, 204, "Enable license keys failed: {} {}", resp.status, resp.text ); // Generate a license key let resp = h .client .post_form(&format!("/api/items/{item_id}/keys"), "") .await; assert_eq!( resp.status, 200, "Generate key failed: {} {}", resp.status, resp.text ); let key: Value = resp.json(); let key_code = key["key_code"].as_str().expect("key should have key_code"); // Validate the key (public endpoint, no auth required) let resp = h .client .post_json( "/api/keys/validate", &format!( r#"{{"key": "{key_code}", "machine_id": "machine-001", "label": "My Laptop"}}"# ), ) .await; assert_eq!(resp.status, 200, "Validate key failed: {}", resp.text); let validation: Value = resp.json(); assert_eq!(validation["valid"], true, "Key should be valid"); // Check key status let resp = h.client.get(&format!("/api/keys/{key_code}/status")).await; assert_eq!(resp.status, 200, "Key status failed: {}", resp.text); let status: Value = resp.json(); assert_eq!(status["valid"], true); assert_eq!( status["license"]["activation_count"], 1, "Should have 1 activation" ); // The POST form (key in the body, not the URL) must return the same thing, // it exists so the purchase-proof key doesn't land in access logs. let resp = h .client .post_json("/api/keys/status", &format!(r#"{{"key": "{key_code}"}}"#)) .await; assert_eq!(resp.status, 200, "POST key status failed: {}", resp.text); let post_status: Value = resp.json(); assert_eq!(post_status["valid"], true); assert_eq!(post_status["license"]["activation_count"], 1); // Deactivate let resp = h .client .post_json( "/api/keys/deactivate", &format!(r#"{{"key": "{key_code}", "machine_id": "machine-001"}}"#), ) .await; assert_eq!(resp.status, 200, "Deactivate key failed: {}", resp.text); let deactivation: Value = resp.json(); assert_eq!(deactivation["success"], true); // Check status again, activation count should be 0 let resp = h.client.get(&format!("/api/keys/{key_code}/status")).await; let status: Value = resp.json(); assert_eq!( status["license"]["activation_count"], 0, "Should have 0 activations after deactivation" ); } /// Helper: create a creator with a project and license-key-enabled item. /// Returns (item_id, key_code) after generating one key. async fn setup_creator_with_item( h: &mut TestHarness, username: &str, max_activations: u32, ) -> String { let user_id = h .signup(username, &format!("{username}@example.com"), "password123") .await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login(username, "password123").await; let resp = h .client .post_form("/api/projects", "slug=software&title=Software") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), "title=My+Plugin&price_cents=0&item_type=plugin", ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap().to_string(); let resp = h .client .put_form( &format!("/api/items/{item_id}/license-settings"), &format!("enable_license_keys=on&default_max_activations={max_activations}"), ) .await; assert_eq!( resp.status, 204, "Enable license keys failed: {}", resp.text ); item_id } async fn generate_key(h: &mut TestHarness, item_id: &str) -> Value { let resp = h .client .post_form(&format!("/api/items/{item_id}/keys"), "") .await; assert_eq!(resp.status, 200, "Generate key failed: {}", resp.text); resp.json() } #[tokio::test] async fn max_activations_enforced() { let mut h = TestHarness::new().await; let item_id = setup_creator_with_item(&mut h, "maxact", 2).await; let key = generate_key(&mut h, &item_id).await; let key_code = key["key_code"].as_str().unwrap(); // First activation, should succeed let resp = h .client .post_json( "/api/keys/validate", &format!( r#"{{"key": "{key_code}", "machine_id": "machine-001", "label": "Machine 1"}}"# ), ) .await; assert_eq!(resp.status, 200, "{}", resp.text); let v: Value = resp.json(); assert_eq!(v["valid"], true, "First activation should succeed"); // Second activation, should succeed let resp = h .client .post_json( "/api/keys/validate", &format!( r#"{{"key": "{key_code}", "machine_id": "machine-002", "label": "Machine 2"}}"# ), ) .await; assert_eq!(resp.status, 200, "{}", resp.text); let v: Value = resp.json(); assert_eq!(v["valid"], true, "Second activation should succeed"); // Third activation, should fail (limit is 2) let resp = h .client .post_json( "/api/keys/validate", &format!( r#"{{"key": "{key_code}", "machine_id": "machine-003", "label": "Machine 3"}}"# ), ) .await; let v: Value = resp.json(); assert_eq!( v["valid"], false, "Third activation should be rejected (max=2)" ); } #[tokio::test] async fn invalid_key_rejected() { let mut h = TestHarness::new().await; let resp = h .client .post_json( "/api/keys/validate", r#"{"key": "BOGUS-KEY-DOES-NOT-EXIST", "machine_id": "m1", "label": "Test"}"#, ) .await; // KeyCode deserialization rejects invalid format before reaching the handler assert_eq!( resp.status, 422, "Bogus key should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn revoke_key_then_validate_fails() { let mut h = TestHarness::new().await; let item_id = setup_creator_with_item(&mut h, "revoker", 3).await; let key = generate_key(&mut h, &item_id).await; let key_code = key["key_code"].as_str().unwrap(); // Validate first, should succeed let resp = h .client .post_json( "/api/keys/validate", &format!(r#"{{"key": "{key_code}", "machine_id": "machine-001", "label": "Laptop"}}"#), ) .await; assert_eq!(resp.status, 200, "{}", resp.text); let v: Value = resp.json(); assert_eq!(v["valid"], true); // Get the key's database ID, try the response first, fall back to DB query let key_id = if let Some(id) = key["id"].as_str() { id.to_string() } else { let row: (sqlx::types::Uuid,) = sqlx::query_as("SELECT id FROM license_keys WHERE key_code = $1") .bind(key_code) .fetch_one(&h.db) .await .expect("Key should exist in DB"); row.0.to_string() }; // Revoke the key (creator-only endpoint) let resp = h .client .post_form(&format!("/api/keys/{key_id}/revoke"), "") .await; assert_eq!( resp.status, 204, "Revoke key failed: {} {}", resp.status, resp.text ); // Validate again, should fail let resp = h .client .post_json( "/api/keys/validate", &format!(r#"{{"key": "{key_code}", "machine_id": "machine-002", "label": "Desktop"}}"#), ) .await; let v: Value = resp.json(); assert_eq!(v["valid"], false, "Revoked key should not validate"); } #[tokio::test] async fn list_keys() { let mut h = TestHarness::new().await; let item_id = setup_creator_with_item(&mut h, "lister", 3).await; // Generate 3 keys for _ in 0..3 { generate_key(&mut h, &item_id).await; } let resp = h.client.get(&format!("/api/items/{item_id}/keys")).await; assert_eq!(resp.status, 200, "List keys failed: {}", resp.text); let body: Value = resp.json(); let arr = body["data"] .as_array() .expect("Response should have a 'data' array"); assert_eq!(arr.len(), 3, "Should have 3 keys"); } #[tokio::test] async fn v1_license_endpoints() { let mut h = TestHarness::new().await; let item_id = setup_creator_with_item(&mut h, "v1user", 3).await; let key = generate_key(&mut h, &item_id).await; let key_code = key["key_code"].as_str().unwrap(); // Enable license verification on the project (required for v1 verify) let project_id: uuid::Uuid = sqlx::query_scalar("SELECT project_id FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); sqlx::query("UPDATE projects SET license_verification_enabled = true WHERE id = $1") .bind(project_id) .execute(&h.db) .await .unwrap(); // POST /api/v1/license/verify let resp = h .client .post_json( "/api/v1/license/verify", &format!(r#"{{"key": "{key_code}", "machine_fingerprint": "machine-v1"}}"#), ) .await; assert_eq!( resp.status, 200, "v1 verify failed: {} {}", resp.status, resp.text ); let v: Value = resp.json(); assert_eq!(v["valid"], true, "v1 verify should return valid=true"); // POST /api/v1/license/deactivate let resp = h .client .post_json( "/api/v1/license/deactivate", &format!(r#"{{"key": "{key_code}", "machine_fingerprint": "machine-v1"}}"#), ) .await; assert_eq!( resp.status, 200, "v1 deactivate failed: {} {}", resp.status, resp.text ); let v: Value = resp.json(); assert_eq!( v["success"], true, "v1 deactivate should return success=true" ); } /// Concurrent activation race: for a single-seat key, many machines activating /// at once must resolve to exactly one winner. `try_create_activation` takes the /// `license_keys` row `FOR UPDATE` and recomputes the count under the lock, so /// the cap holds even when every request reads the pre-lock count of 0. #[tokio::test] async fn concurrent_activation_race_respects_single_seat() { use makenotwork::db::{self, KeyCode}; let mut h = TestHarness::new().await; let item_id = setup_creator_with_item(&mut h, "raceact", 1).await; let key = generate_key(&mut h, &item_id).await; let key_code = KeyCode::new(key["key_code"].as_str().unwrap()).unwrap(); let key_row = db::license_keys::get_license_key_by_code(&h.db, &key_code) .await .unwrap() .expect("generated key exists"); let key_id = key_row.id; // Fire many concurrent activations, each on a distinct machine. let mut handles = Vec::new(); for i in 0..16 { let pool = h.db.clone(); handles.push(tokio::spawn(async move { db::license_keys::try_create_activation( &pool, key_id, &format!("machine-{i}"), Some("race"), ) .await .unwrap() })); } let mut granted = 0; for handle in handles { if handle.await.unwrap().is_some() { granted += 1; } } assert_eq!( granted, 1, "exactly one machine wins the single seat under FOR UPDATE" ); let count = db::license_keys::get_activation_count(&h.db, key_id) .await .unwrap(); assert_eq!( count, 1, "denormalized activation_count matches the single winner" ); } /// Re-activating the same machine is idempotent and never consumes extra seats, /// even racing against itself, the machine_id upsert path returns the existing /// row rather than allocating a new activation. #[tokio::test] async fn concurrent_same_machine_reactivation_is_idempotent() { use makenotwork::db::{self, KeyCode}; let mut h = TestHarness::new().await; let item_id = setup_creator_with_item(&mut h, "react", 1).await; let key = generate_key(&mut h, &item_id).await; let key_code = KeyCode::new(key["key_code"].as_str().unwrap()).unwrap(); let key_id = db::license_keys::get_license_key_by_code(&h.db, &key_code) .await .unwrap() .unwrap() .id; let mut handles = Vec::new(); for _ in 0..8 { let pool = h.db.clone(); handles.push(tokio::spawn(async move { db::license_keys::try_create_activation(&pool, key_id, "same-machine", Some("dup")) .await .unwrap() })); } for handle in handles { assert!( handle.await.unwrap().is_some(), "same-machine re-activation always succeeds" ); } let count = db::license_keys::get_activation_count(&h.db, key_id) .await .unwrap(); assert_eq!( count, 1, "one machine == one seat regardless of concurrent re-activations" ); }