Skip to main content

max / makenotwork

14.4 KB · 472 lines History Blame Raw
1 //! License key workflow: create item -> enable keys -> generate key ->
2 //! validate -> deactivate -> check status
3
4 use crate::harness::TestHarness;
5 use serde_json::Value;
6
7 #[tokio::test]
8 async fn license_key_lifecycle() {
9 let mut h = TestHarness::new().await;
10
11 // Setup: creator with project and item
12 let user_id = h
13 .signup("keymaker", "keymaker@example.com", "password123")
14 .await;
15 h.grant_creator(user_id).await;
16 h.client.post_form("/logout", "").await;
17 h.login("keymaker", "password123").await;
18
19 let resp = h
20 .client
21 .post_form("/api/projects", "slug=software&title=Software")
22 .await;
23 let project: Value = resp.json();
24 let project_id = project["id"].as_str().unwrap();
25
26 let resp = h
27 .client
28 .post_form(
29 &format!("/api/projects/{project_id}/items"),
30 "title=My+Plugin&price_cents=0&item_type=plugin",
31 )
32 .await;
33 let item: Value = resp.json();
34 let item_id = item["id"].as_str().unwrap();
35
36 // Enable license keys with max 3 activations
37 let resp = h
38 .client
39 .put_form(
40 &format!("/api/items/{item_id}/license-settings"),
41 "enable_license_keys=on&default_max_activations=3",
42 )
43 .await;
44 assert_eq!(
45 resp.status, 204,
46 "Enable license keys failed: {} {}",
47 resp.status, resp.text
48 );
49
50 // Generate a license key
51 let resp = h
52 .client
53 .post_form(&format!("/api/items/{item_id}/keys"), "")
54 .await;
55 assert_eq!(
56 resp.status, 200,
57 "Generate key failed: {} {}",
58 resp.status, resp.text
59 );
60 let key: Value = resp.json();
61 let key_code = key["key_code"].as_str().expect("key should have key_code");
62
63 // Validate the key (public endpoint, no auth required)
64 let resp = h
65 .client
66 .post_json(
67 "/api/keys/validate",
68 &format!(
69 r#"{{"key": "{key_code}", "machine_id": "machine-001", "label": "My Laptop"}}"#
70 ),
71 )
72 .await;
73 assert_eq!(resp.status, 200, "Validate key failed: {}", resp.text);
74 let validation: Value = resp.json();
75 assert_eq!(validation["valid"], true, "Key should be valid");
76
77 // Check key status
78 let resp = h.client.get(&format!("/api/keys/{key_code}/status")).await;
79 assert_eq!(resp.status, 200, "Key status failed: {}", resp.text);
80 let status: Value = resp.json();
81 assert_eq!(status["valid"], true);
82 assert_eq!(
83 status["license"]["activation_count"], 1,
84 "Should have 1 activation"
85 );
86
87 // The POST form (key in the body, not the URL) must return the same thing,
88 // it exists so the purchase-proof key doesn't land in access logs.
89 let resp = h
90 .client
91 .post_json("/api/keys/status", &format!(r#"{{"key": "{key_code}"}}"#))
92 .await;
93 assert_eq!(resp.status, 200, "POST key status failed: {}", resp.text);
94 let post_status: Value = resp.json();
95 assert_eq!(post_status["valid"], true);
96 assert_eq!(post_status["license"]["activation_count"], 1);
97
98 // Deactivate
99 let resp = h
100 .client
101 .post_json(
102 "/api/keys/deactivate",
103 &format!(r#"{{"key": "{key_code}", "machine_id": "machine-001"}}"#),
104 )
105 .await;
106 assert_eq!(resp.status, 200, "Deactivate key failed: {}", resp.text);
107 let deactivation: Value = resp.json();
108 assert_eq!(deactivation["success"], true);
109
110 // Check status again, activation count should be 0
111 let resp = h.client.get(&format!("/api/keys/{key_code}/status")).await;
112 let status: Value = resp.json();
113 assert_eq!(
114 status["license"]["activation_count"], 0,
115 "Should have 0 activations after deactivation"
116 );
117 }
118
119 /// Helper: create a creator with a project and license-key-enabled item.
120 /// Returns (item_id, key_code) after generating one key.
121 async fn setup_creator_with_item(
122 h: &mut TestHarness,
123 username: &str,
124 max_activations: u32,
125 ) -> String {
126 let user_id = h
127 .signup(username, &format!("{username}@example.com"), "password123")
128 .await;
129 h.grant_creator(user_id).await;
130 h.client.post_form("/logout", "").await;
131 h.login(username, "password123").await;
132
133 let resp = h
134 .client
135 .post_form("/api/projects", "slug=software&title=Software")
136 .await;
137 let project: Value = resp.json();
138 let project_id = project["id"].as_str().unwrap();
139
140 let resp = h
141 .client
142 .post_form(
143 &format!("/api/projects/{project_id}/items"),
144 "title=My+Plugin&price_cents=0&item_type=plugin",
145 )
146 .await;
147 let item: Value = resp.json();
148 let item_id = item["id"].as_str().unwrap().to_string();
149
150 let resp = h
151 .client
152 .put_form(
153 &format!("/api/items/{item_id}/license-settings"),
154 &format!("enable_license_keys=on&default_max_activations={max_activations}"),
155 )
156 .await;
157 assert_eq!(
158 resp.status, 204,
159 "Enable license keys failed: {}",
160 resp.text
161 );
162
163 item_id
164 }
165
166 async fn generate_key(h: &mut TestHarness, item_id: &str) -> Value {
167 let resp = h
168 .client
169 .post_form(&format!("/api/items/{item_id}/keys"), "")
170 .await;
171 assert_eq!(resp.status, 200, "Generate key failed: {}", resp.text);
172 resp.json()
173 }
174
175 #[tokio::test]
176 async fn max_activations_enforced() {
177 let mut h = TestHarness::new().await;
178 let item_id = setup_creator_with_item(&mut h, "maxact", 2).await;
179 let key = generate_key(&mut h, &item_id).await;
180 let key_code = key["key_code"].as_str().unwrap();
181
182 // First activation, should succeed
183 let resp = h
184 .client
185 .post_json(
186 "/api/keys/validate",
187 &format!(
188 r#"{{"key": "{key_code}", "machine_id": "machine-001", "label": "Machine 1"}}"#
189 ),
190 )
191 .await;
192 assert_eq!(resp.status, 200, "{}", resp.text);
193 let v: Value = resp.json();
194 assert_eq!(v["valid"], true, "First activation should succeed");
195
196 // Second activation, should succeed
197 let resp = h
198 .client
199 .post_json(
200 "/api/keys/validate",
201 &format!(
202 r#"{{"key": "{key_code}", "machine_id": "machine-002", "label": "Machine 2"}}"#
203 ),
204 )
205 .await;
206 assert_eq!(resp.status, 200, "{}", resp.text);
207 let v: Value = resp.json();
208 assert_eq!(v["valid"], true, "Second activation should succeed");
209
210 // Third activation, should fail (limit is 2)
211 let resp = h
212 .client
213 .post_json(
214 "/api/keys/validate",
215 &format!(
216 r#"{{"key": "{key_code}", "machine_id": "machine-003", "label": "Machine 3"}}"#
217 ),
218 )
219 .await;
220 let v: Value = resp.json();
221 assert_eq!(
222 v["valid"], false,
223 "Third activation should be rejected (max=2)"
224 );
225 }
226
227 #[tokio::test]
228 async fn invalid_key_rejected() {
229 let mut h = TestHarness::new().await;
230
231 let resp = h
232 .client
233 .post_json(
234 "/api/keys/validate",
235 r#"{"key": "BOGUS-KEY-DOES-NOT-EXIST", "machine_id": "m1", "label": "Test"}"#,
236 )
237 .await;
238 // KeyCode deserialization rejects invalid format before reaching the handler
239 assert_eq!(
240 resp.status, 422,
241 "Bogus key should be rejected: {} {}",
242 resp.status, resp.text
243 );
244 }
245
246 #[tokio::test]
247 async fn revoke_key_then_validate_fails() {
248 let mut h = TestHarness::new().await;
249 let item_id = setup_creator_with_item(&mut h, "revoker", 3).await;
250 let key = generate_key(&mut h, &item_id).await;
251 let key_code = key["key_code"].as_str().unwrap();
252
253 // Validate first, should succeed
254 let resp = h
255 .client
256 .post_json(
257 "/api/keys/validate",
258 &format!(r#"{{"key": "{key_code}", "machine_id": "machine-001", "label": "Laptop"}}"#),
259 )
260 .await;
261 assert_eq!(resp.status, 200, "{}", resp.text);
262 let v: Value = resp.json();
263 assert_eq!(v["valid"], true);
264
265 // Get the key's database ID, try the response first, fall back to DB query
266 let key_id = if let Some(id) = key["id"].as_str() {
267 id.to_string()
268 } else {
269 let row: (sqlx::types::Uuid,) =
270 sqlx::query_as("SELECT id FROM license_keys WHERE key_code = $1")
271 .bind(key_code)
272 .fetch_one(&h.db)
273 .await
274 .expect("Key should exist in DB");
275 row.0.to_string()
276 };
277
278 // Revoke the key (creator-only endpoint)
279 let resp = h
280 .client
281 .post_form(&format!("/api/keys/{key_id}/revoke"), "")
282 .await;
283 assert_eq!(
284 resp.status, 204,
285 "Revoke key failed: {} {}",
286 resp.status, resp.text
287 );
288
289 // Validate again, should fail
290 let resp = h
291 .client
292 .post_json(
293 "/api/keys/validate",
294 &format!(r#"{{"key": "{key_code}", "machine_id": "machine-002", "label": "Desktop"}}"#),
295 )
296 .await;
297 let v: Value = resp.json();
298 assert_eq!(v["valid"], false, "Revoked key should not validate");
299 }
300
301 #[tokio::test]
302 async fn list_keys() {
303 let mut h = TestHarness::new().await;
304 let item_id = setup_creator_with_item(&mut h, "lister", 3).await;
305
306 // Generate 3 keys
307 for _ in 0..3 {
308 generate_key(&mut h, &item_id).await;
309 }
310
311 let resp = h.client.get(&format!("/api/items/{item_id}/keys")).await;
312 assert_eq!(resp.status, 200, "List keys failed: {}", resp.text);
313 let body: Value = resp.json();
314 let arr = body["data"]
315 .as_array()
316 .expect("Response should have a 'data' array");
317 assert_eq!(arr.len(), 3, "Should have 3 keys");
318 }
319
320 #[tokio::test]
321 async fn v1_license_endpoints() {
322 let mut h = TestHarness::new().await;
323 let item_id = setup_creator_with_item(&mut h, "v1user", 3).await;
324 let key = generate_key(&mut h, &item_id).await;
325 let key_code = key["key_code"].as_str().unwrap();
326
327 // Enable license verification on the project (required for v1 verify)
328 let project_id: uuid::Uuid =
329 sqlx::query_scalar("SELECT project_id FROM items WHERE id = $1::uuid")
330 .bind(&item_id)
331 .fetch_one(&h.db)
332 .await
333 .unwrap();
334 sqlx::query("UPDATE projects SET license_verification_enabled = true WHERE id = $1")
335 .bind(project_id)
336 .execute(&h.db)
337 .await
338 .unwrap();
339
340 // POST /api/v1/license/verify
341 let resp = h
342 .client
343 .post_json(
344 "/api/v1/license/verify",
345 &format!(r#"{{"key": "{key_code}", "machine_fingerprint": "machine-v1"}}"#),
346 )
347 .await;
348 assert_eq!(
349 resp.status, 200,
350 "v1 verify failed: {} {}",
351 resp.status, resp.text
352 );
353 let v: Value = resp.json();
354 assert_eq!(v["valid"], true, "v1 verify should return valid=true");
355
356 // POST /api/v1/license/deactivate
357 let resp = h
358 .client
359 .post_json(
360 "/api/v1/license/deactivate",
361 &format!(r#"{{"key": "{key_code}", "machine_fingerprint": "machine-v1"}}"#),
362 )
363 .await;
364 assert_eq!(
365 resp.status, 200,
366 "v1 deactivate failed: {} {}",
367 resp.status, resp.text
368 );
369 let v: Value = resp.json();
370 assert_eq!(
371 v["success"], true,
372 "v1 deactivate should return success=true"
373 );
374 }
375
376 /// Concurrent activation race: for a single-seat key, many machines activating
377 /// at once must resolve to exactly one winner. `try_create_activation` takes the
378 /// `license_keys` row `FOR UPDATE` and recomputes the count under the lock, so
379 /// the cap holds even when every request reads the pre-lock count of 0.
380 #[tokio::test]
381 async fn concurrent_activation_race_respects_single_seat() {
382 use makenotwork::db::{self, KeyCode};
383
384 let mut h = TestHarness::new().await;
385 let item_id = setup_creator_with_item(&mut h, "raceact", 1).await;
386 let key = generate_key(&mut h, &item_id).await;
387 let key_code = KeyCode::new(key["key_code"].as_str().unwrap()).unwrap();
388
389 let key_row = db::license_keys::get_license_key_by_code(&h.db, &key_code)
390 .await
391 .unwrap()
392 .expect("generated key exists");
393 let key_id = key_row.id;
394
395 // Fire many concurrent activations, each on a distinct machine.
396 let mut handles = Vec::new();
397 for i in 0..16 {
398 let pool = h.db.clone();
399 handles.push(tokio::spawn(async move {
400 db::license_keys::try_create_activation(
401 &pool,
402 key_id,
403 &format!("machine-{i}"),
404 Some("race"),
405 )
406 .await
407 .unwrap()
408 }));
409 }
410
411 let mut granted = 0;
412 for handle in handles {
413 if handle.await.unwrap().is_some() {
414 granted += 1;
415 }
416 }
417 assert_eq!(
418 granted, 1,
419 "exactly one machine wins the single seat under FOR UPDATE"
420 );
421
422 let count = db::license_keys::get_activation_count(&h.db, key_id)
423 .await
424 .unwrap();
425 assert_eq!(
426 count, 1,
427 "denormalized activation_count matches the single winner"
428 );
429 }
430
431 /// Re-activating the same machine is idempotent and never consumes extra seats,
432 /// even racing against itself, the machine_id upsert path returns the existing
433 /// row rather than allocating a new activation.
434 #[tokio::test]
435 async fn concurrent_same_machine_reactivation_is_idempotent() {
436 use makenotwork::db::{self, KeyCode};
437
438 let mut h = TestHarness::new().await;
439 let item_id = setup_creator_with_item(&mut h, "react", 1).await;
440 let key = generate_key(&mut h, &item_id).await;
441 let key_code = KeyCode::new(key["key_code"].as_str().unwrap()).unwrap();
442 let key_id = db::license_keys::get_license_key_by_code(&h.db, &key_code)
443 .await
444 .unwrap()
445 .unwrap()
446 .id;
447
448 let mut handles = Vec::new();
449 for _ in 0..8 {
450 let pool = h.db.clone();
451 handles.push(tokio::spawn(async move {
452 db::license_keys::try_create_activation(&pool, key_id, "same-machine", Some("dup"))
453 .await
454 .unwrap()
455 }));
456 }
457 for handle in handles {
458 assert!(
459 handle.await.unwrap().is_some(),
460 "same-machine re-activation always succeeds"
461 );
462 }
463
464 let count = db::license_keys::get_activation_count(&h.db, key_id)
465 .await
466 .unwrap();
467 assert_eq!(
468 count, 1,
469 "one machine == one seat regardless of concurrent re-activations"
470 );
471 }
472