1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use chrono::{DateTime, Utc};
6use hmac::{Hmac, Mac};
7use serde::{Deserialize, Serialize};
8use sha2::Sha256;
9use subtle::ConstantTimeEq;
10
11use crate::secure_io;
12
13pub mod manual;
14
15const LS_API_BASE: &str = "https://api.lemonsqueezy.com/v1";
16const VALIDATE_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24);
17const OFFLINE_GRACE: Duration = Duration::from_secs(60 * 60 * 24 * 30);
18const FILE_NAME: &str = "supporter.json";
19const MAX_FILE_BYTES: u64 = 16 * 1024;
24const FUTURE_CLOCK_SKEW_TOLERANCE: chrono::Duration = chrono::Duration::hours(1);
27const RECORD_HMAC_KEY: &[u8] = b"entracte/supporter-record/v1\0\
35 this-key-binds-supporter-records-against-text-editor-tampering";
36
37#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39pub enum SupporterSource {
40 #[default]
41 LemonSqueezy,
42 Manual,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46pub struct SupporterRecord {
47 pub license_key: String,
48 pub instance_id: String,
49 pub activated_at: DateTime<Utc>,
50 pub last_validated_at: DateTime<Utc>,
51 #[serde(default)]
52 pub source: SupporterSource,
53 #[serde(default)]
58 pub signature: String,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct SupporterStatus {
63 pub is_supporter: bool,
64 pub masked_key: Option<String>,
65 pub last_validated_at: Option<DateTime<Utc>>,
66}
67
68impl SupporterStatus {
69 pub fn from_record(record: Option<&SupporterRecord>, now: DateTime<Utc>) -> Self {
70 match record {
71 Some(r) if record_is_active(r, now) => Self {
72 is_supporter: true,
73 masked_key: Some(mask_key(&r.license_key)),
74 last_validated_at: Some(r.last_validated_at),
75 },
76 Some(r) => Self {
77 is_supporter: false,
78 masked_key: Some(mask_key(&r.license_key)),
79 last_validated_at: Some(r.last_validated_at),
80 },
81 None => Self {
82 is_supporter: false,
83 masked_key: None,
84 last_validated_at: None,
85 },
86 }
87 }
88}
89
90fn record_is_active(record: &SupporterRecord, now: DateTime<Utc>) -> bool {
91 if !temporal_sanity(record, now) {
92 return false;
93 }
94 match record.source {
95 SupporterSource::Manual => manual::verify(&record.license_key).is_ok(),
96 SupporterSource::LemonSqueezy => is_within_grace(record.last_validated_at, now),
97 }
98}
99
100fn temporal_sanity(record: &SupporterRecord, now: DateTime<Utc>) -> bool {
106 let max_future = now + FUTURE_CLOCK_SKEW_TOLERANCE;
107 record.activated_at <= max_future
108 && record.last_validated_at <= max_future
109 && record.activated_at <= record.last_validated_at
110}
111
112fn canonical_bytes(record: &SupporterRecord) -> Vec<u8> {
117 let source_tag: u8 = match record.source {
118 SupporterSource::LemonSqueezy => 1,
119 SupporterSource::Manual => 2,
120 };
121 let mut out = Vec::with_capacity(
122 1 + 8 + 8 + 1 + 4 + record.license_key.len() + 4 + record.instance_id.len(),
123 );
124 out.push(1u8); out.extend_from_slice(&record.activated_at.timestamp().to_be_bytes());
126 out.extend_from_slice(&record.last_validated_at.timestamp().to_be_bytes());
127 out.push(source_tag);
128 let key_bytes = record.license_key.as_bytes();
129 out.extend_from_slice(&(key_bytes.len() as u32).to_be_bytes());
130 out.extend_from_slice(key_bytes);
131 let inst_bytes = record.instance_id.as_bytes();
132 out.extend_from_slice(&(inst_bytes.len() as u32).to_be_bytes());
133 out.extend_from_slice(inst_bytes);
134 out
135}
136
137fn compute_signature(record: &SupporterRecord) -> String {
138 let mut mac =
139 Hmac::<Sha256>::new_from_slice(RECORD_HMAC_KEY).expect("HMAC accepts any key length");
140 mac.update(&canonical_bytes(record));
141 hex::encode(mac.finalize().into_bytes())
142}
143
144fn signature_matches(record: &SupporterRecord) -> bool {
145 if record.signature.is_empty() {
146 return false;
147 }
148 let expected = compute_signature(record);
149 expected
150 .as_bytes()
151 .ct_eq(record.signature.as_bytes())
152 .into()
153}
154
155pub fn file_path(data_dir: &Path) -> PathBuf {
156 data_dir.join(FILE_NAME)
157}
158
159pub fn load(path: &Path) -> Option<SupporterRecord> {
166 let metadata = fs::metadata(path).ok()?;
167 if metadata.len() > MAX_FILE_BYTES {
168 log::warn!(
169 "supporter.json exceeds {MAX_FILE_BYTES} bytes ({} bytes on disk); refusing to parse",
170 metadata.len()
171 );
172 return None;
173 }
174 let text = fs::read_to_string(path).ok()?;
175 let record: SupporterRecord = match serde_json::from_str(&text) {
176 Ok(r) => r,
177 Err(e) => {
178 log::warn!("supporter.json failed to parse: {e}");
179 return None;
180 }
181 };
182 if !record.signature.is_empty() && !signature_matches(&record) {
183 log::warn!("supporter.json signature mismatch; treating as tampered");
184 return None;
185 }
186 Some(record)
187}
188
189pub fn save(path: &Path, record: &SupporterRecord) -> std::io::Result<()> {
190 let mut signed = record.clone();
191 signed.signature = compute_signature(&signed);
192 let body = serde_json::to_string_pretty(&signed).map_err(std::io::Error::other)?;
193 secure_io::write_user_only(path, body.as_bytes())
194}
195
196pub fn delete(path: &Path) -> std::io::Result<()> {
197 match fs::remove_file(path) {
198 Ok(()) => Ok(()),
199 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
200 Err(e) => Err(e),
201 }
202}
203
204pub fn is_supporter_now(path: &Path) -> bool {
209 match load(path) {
210 Some(r) => record_is_active(&r, Utc::now()),
211 None => false,
212 }
213}
214
215pub fn is_within_grace(last_validated_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
216 let elapsed = now.signed_duration_since(last_validated_at);
217 elapsed >= chrono::Duration::zero()
218 && elapsed <= chrono::Duration::from_std(OFFLINE_GRACE).unwrap()
219}
220
221pub fn needs_revalidation(last_validated_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
222 let elapsed = now.signed_duration_since(last_validated_at);
223 elapsed >= chrono::Duration::from_std(VALIDATE_INTERVAL).unwrap()
224}
225
226pub fn needs_remote_revalidation(record: &SupporterRecord, now: DateTime<Utc>) -> bool {
230 !matches!(record.source, SupporterSource::Manual)
231 && needs_revalidation(record.last_validated_at, now)
232}
233
234pub async fn activate_with(
241 path: &Path,
242 client: &reqwest::Client,
243 key: &str,
244 instance_name: &str,
245 now: DateTime<Utc>,
246) -> Result<SupporterRecord, String> {
247 activate_with_base(path, client, LS_API_BASE, None, key, instance_name, now).await
248}
249
250pub(crate) async fn activate_with_base(
254 path: &Path,
255 client: &reqwest::Client,
256 ls_base: &str,
257 manual_verifier: Option<&ed25519_dalek::VerifyingKey>,
258 key: &str,
259 instance_name: &str,
260 now: DateTime<Utc>,
261) -> Result<SupporterRecord, String> {
262 let key = key.trim();
263 if key.is_empty() {
264 return Err("license key is empty".to_string());
265 }
266 let record = if manual::looks_like_manual_token(key) {
267 match manual_verifier {
268 Some(vk) => manual::verify_with(key, vk)?,
269 None => manual::verify(key)?,
270 };
271 SupporterRecord {
272 license_key: key.to_string(),
273 instance_id: String::new(),
274 activated_at: now,
275 last_validated_at: now,
276 source: SupporterSource::Manual,
277 signature: String::new(),
278 }
279 } else {
280 let instance_id = activate_remote_at(client, ls_base, key, instance_name).await?;
281 SupporterRecord {
282 license_key: key.to_string(),
283 instance_id,
284 activated_at: now,
285 last_validated_at: now,
286 source: SupporterSource::LemonSqueezy,
287 signature: String::new(),
288 }
289 };
290 save(path, &record).map_err(|e| e.to_string())?;
291 Ok(record)
292}
293
294pub fn mask_key(key: &str) -> String {
295 let trimmed = key.trim();
296 let tail: String = trimmed
297 .chars()
298 .rev()
299 .take(4)
300 .collect::<Vec<_>>()
301 .into_iter()
302 .rev()
303 .collect();
304 format!("****-****-****-{tail}")
305}
306
307#[derive(Debug, Deserialize)]
308struct LsActivateResponse {
309 activated: bool,
310 error: Option<String>,
311 instance: Option<LsInstance>,
312}
313
314#[derive(Debug, Deserialize)]
315struct LsValidateResponse {
316 valid: bool,
317 error: Option<String>,
318}
319
320#[derive(Debug, Deserialize)]
321struct LsInstance {
322 id: String,
323}
324
325pub async fn activate_remote(
326 client: &reqwest::Client,
327 key: &str,
328 instance_name: &str,
329) -> Result<String, String> {
330 activate_remote_at(client, LS_API_BASE, key, instance_name).await
331}
332
333pub(crate) async fn activate_remote_at(
337 client: &reqwest::Client,
338 base: &str,
339 key: &str,
340 instance_name: &str,
341) -> Result<String, String> {
342 let resp = client
343 .post(format!("{base}/licenses/activate"))
344 .header("Accept", "application/json")
345 .form(&[("license_key", key), ("instance_name", instance_name)])
346 .send()
347 .await
348 .map_err(|e| format!("network: {e}"))?;
349 let parsed: LsActivateResponse = resp
350 .json()
351 .await
352 .map_err(|e| format!("invalid response from Lemon Squeezy: {e}"))?;
353 if parsed.activated {
354 parsed.instance.map(|i| i.id).ok_or_else(|| {
355 "Lemon Squeezy returned activated=true without an instance id".to_string()
356 })
357 } else {
358 Err(parsed
359 .error
360 .unwrap_or_else(|| "license activation refused".to_string()))
361 }
362}
363
364pub async fn validate_remote(
365 client: &reqwest::Client,
366 key: &str,
367 instance_id: &str,
368) -> Result<bool, String> {
369 validate_remote_at(client, LS_API_BASE, key, instance_id).await
370}
371
372pub(crate) async fn validate_remote_at(
374 client: &reqwest::Client,
375 base: &str,
376 key: &str,
377 instance_id: &str,
378) -> Result<bool, String> {
379 let resp = client
380 .post(format!("{base}/licenses/validate"))
381 .header("Accept", "application/json")
382 .form(&[("license_key", key), ("instance_id", instance_id)])
383 .send()
384 .await
385 .map_err(|e| format!("network: {e}"))?;
386 let parsed: LsValidateResponse = resp
387 .json()
388 .await
389 .map_err(|e| format!("invalid response from Lemon Squeezy: {e}"))?;
390 if let Some(err) = parsed.error {
391 return Err(err);
392 }
393 Ok(parsed.valid)
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use ed25519_dalek::SigningKey;
400
401 fn epoch(seconds_ago: i64) -> DateTime<Utc> {
402 Utc::now() - chrono::Duration::seconds(seconds_ago)
403 }
404
405 fn lemonsqueezy_record(
406 license_key: &str,
407 instance_id: &str,
408 activated_at: DateTime<Utc>,
409 last_validated_at: DateTime<Utc>,
410 ) -> SupporterRecord {
411 SupporterRecord {
412 license_key: license_key.to_string(),
413 instance_id: instance_id.to_string(),
414 activated_at,
415 last_validated_at,
416 source: SupporterSource::LemonSqueezy,
417 signature: String::new(),
418 }
419 }
420
421 fn fresh_signing_key() -> SigningKey {
422 let mut seed = [0u8; 32];
423 getrandom::getrandom(&mut seed).unwrap();
424 SigningKey::from_bytes(&seed)
425 }
426
427 #[test]
428 fn mask_key_keeps_last_four() {
429 assert_eq!(mask_key("ABCDEFGH-1234-5678-2A41"), "****-****-****-2A41");
430 assert_eq!(mask_key("abc"), "****-****-****-abc");
431 }
432
433 #[test]
434 fn within_grace_for_recent_validation() {
435 let now = Utc::now();
436 let recent = now - chrono::Duration::days(3);
437 assert!(is_within_grace(recent, now));
438 }
439
440 #[test]
441 fn outside_grace_after_thirty_days() {
442 let now = Utc::now();
443 let old = now - chrono::Duration::days(31);
444 assert!(!is_within_grace(old, now));
445 }
446
447 #[test]
448 fn within_grace_rejects_future_timestamps() {
449 let now = Utc::now();
450 let future = now + chrono::Duration::days(1);
451 assert!(!is_within_grace(future, now));
452 }
453
454 #[test]
455 fn temporal_sanity_rejects_validated_before_activated() {
456 let now = Utc::now();
457 let rec = lemonsqueezy_record(
458 "K",
459 "i",
460 now - chrono::Duration::days(1),
461 now - chrono::Duration::days(5),
462 );
463 assert!(!temporal_sanity(&rec, now));
464 assert!(!record_is_active(&rec, now));
465 }
466
467 #[test]
468 fn temporal_sanity_rejects_far_future_timestamps() {
469 let now = Utc::now();
470 let rec = lemonsqueezy_record(
471 "K",
472 "i",
473 now - chrono::Duration::days(1),
474 now + chrono::Duration::days(2),
475 );
476 assert!(!temporal_sanity(&rec, now));
477 assert!(!record_is_active(&rec, now));
478 }
479
480 #[test]
481 fn temporal_sanity_tolerates_minor_clock_skew() {
482 let now = Utc::now();
485 let rec = lemonsqueezy_record(
486 "K",
487 "i",
488 now - chrono::Duration::days(1),
489 now + chrono::Duration::minutes(5),
490 );
491 assert!(temporal_sanity(&rec, now));
492 }
493
494 #[test]
495 fn signature_matches_for_freshly_signed_record() {
496 let now = Utc::now();
497 let mut rec = lemonsqueezy_record(
498 "K-1",
499 "i-1",
500 now - chrono::Duration::hours(2),
501 now - chrono::Duration::minutes(5),
502 );
503 rec.signature = compute_signature(&rec);
504 assert!(signature_matches(&rec));
505 }
506
507 #[test]
508 fn signature_mismatch_when_key_tampered() {
509 let now = Utc::now();
510 let mut rec = lemonsqueezy_record(
511 "K-1",
512 "i-1",
513 now - chrono::Duration::hours(2),
514 now - chrono::Duration::minutes(5),
515 );
516 rec.signature = compute_signature(&rec);
517 rec.license_key = "DIFFERENT".to_string();
518 assert!(!signature_matches(&rec));
519 }
520
521 #[test]
522 fn signature_mismatch_when_timestamps_tampered() {
523 let now = Utc::now();
524 let mut rec = lemonsqueezy_record(
525 "K-1",
526 "i-1",
527 now - chrono::Duration::hours(2),
528 now - chrono::Duration::minutes(5),
529 );
530 rec.signature = compute_signature(&rec);
531 rec.last_validated_at = now + chrono::Duration::days(20);
533 assert!(!signature_matches(&rec));
534 }
535
536 #[test]
537 fn load_rejects_tampered_signature_on_disk() {
538 let p = unique_temp_path("tampered-sig");
539 let rec = lemonsqueezy_record(
540 "ORIG-KEY",
541 "i-1",
542 Utc::now() - chrono::Duration::hours(2),
543 Utc::now() - chrono::Duration::minutes(5),
544 );
545 save(&p, &rec).unwrap();
546 let raw = fs::read_to_string(&p).unwrap();
548 let edited = raw.replace("ORIG-KEY", "FORGED-KEY");
549 fs::write(&p, edited).unwrap();
550 assert!(load(&p).is_none());
552 let _ = fs::remove_file(&p);
553 }
554
555 #[test]
556 fn signature_matches_returns_false_for_empty_signature() {
557 let now = Utc::now();
558 let rec = lemonsqueezy_record(
559 "K",
560 "i",
561 now - chrono::Duration::hours(2),
562 now - chrono::Duration::minutes(5),
563 );
564 assert!(!signature_matches(&rec));
566 }
567
568 #[test]
569 fn load_returns_none_for_malformed_json_on_disk() {
570 let p = unique_temp_path("malformed");
571 fs::write(&p, "{ this is not json").unwrap();
572 assert!(load(&p).is_none());
573 let _ = fs::remove_file(&p);
574 }
575
576 #[test]
577 fn load_accepts_legacy_record_without_signature() {
578 let p = unique_temp_path("legacy-no-sig");
582 let body = serde_json::json!({
583 "license_key": "LEGACY",
584 "instance_id": "i-legacy",
585 "activated_at": Utc::now() - chrono::Duration::days(2),
586 "last_validated_at": Utc::now() - chrono::Duration::minutes(10),
587 "source": "lemon_squeezy",
588 });
589 fs::write(&p, serde_json::to_string(&body).unwrap()).unwrap();
590 let loaded = load(&p).unwrap();
591 assert!(loaded.signature.is_empty());
592 let _ = fs::remove_file(&p);
593 }
594
595 #[test]
596 fn load_rejects_file_larger_than_max_bytes() {
597 let p = unique_temp_path("oversized");
598 let blob = format!(
600 "{{\"license_key\":\"{}\",\"instance_id\":\"i\",\"activated_at\":\"{}\",\"last_validated_at\":\"{}\",\"source\":\"lemon_squeezy\"}}",
601 "X".repeat(MAX_FILE_BYTES as usize),
602 Utc::now().to_rfc3339(),
603 Utc::now().to_rfc3339(),
604 );
605 fs::write(&p, &blob).unwrap();
606 assert!(fs::metadata(&p).unwrap().len() > MAX_FILE_BYTES);
607 assert!(load(&p).is_none());
608 let _ = fs::remove_file(&p);
609 }
610
611 #[test]
612 fn needs_revalidation_after_one_day() {
613 let now = Utc::now();
614 assert!(needs_revalidation(now - chrono::Duration::hours(25), now));
615 assert!(!needs_revalidation(now - chrono::Duration::hours(2), now));
616 }
617
618 #[test]
619 fn status_from_missing_record_is_not_supporter() {
620 let s = SupporterStatus::from_record(None, Utc::now());
621 assert!(!s.is_supporter);
622 assert!(s.masked_key.is_none());
623 }
624
625 #[test]
626 fn status_from_fresh_record_unlocks() {
627 let rec = lemonsqueezy_record("ABCD-1111-2222-3333", "i-1", epoch(86_400), epoch(60));
628 let s = SupporterStatus::from_record(Some(&rec), Utc::now());
629 assert!(s.is_supporter);
630 assert_eq!(s.masked_key.as_deref(), Some("****-****-****-3333"));
631 }
632
633 #[test]
634 fn status_from_stale_record_locks_but_keeps_masked_key() {
635 let now = Utc::now();
636 let rec = lemonsqueezy_record(
637 "ZZZZ-9999-8888-7777",
638 "i-2",
639 now - chrono::Duration::days(60),
640 now - chrono::Duration::days(45),
641 );
642 let s = SupporterStatus::from_record(Some(&rec), now);
643 assert!(!s.is_supporter);
644 assert_eq!(s.masked_key.as_deref(), Some("****-****-****-7777"));
645 }
646
647 #[test]
648 fn save_load_round_trip() {
649 let dir = std::env::temp_dir().join(format!(
650 "entracte-supporter-test-{}-{}",
651 std::process::id(),
652 std::time::SystemTime::now()
653 .duration_since(std::time::UNIX_EPOCH)
654 .unwrap()
655 .as_nanos()
656 ));
657 fs::create_dir_all(&dir).unwrap();
658 let p = file_path(&dir);
659 let rec = lemonsqueezy_record("ABCDEFGH", "abc", Utc::now(), Utc::now());
660 save(&p, &rec).unwrap();
661 let loaded = load(&p).unwrap();
662 assert_eq!(loaded.license_key, rec.license_key);
663 assert_eq!(loaded.instance_id, rec.instance_id);
664 assert_eq!(loaded.activated_at, rec.activated_at);
665 assert_eq!(loaded.last_validated_at, rec.last_validated_at);
666 assert_eq!(loaded.source, rec.source);
667 assert!(!loaded.signature.is_empty(), "save() must sign the record");
668 fs::remove_dir_all(&dir).ok();
669 }
670
671 #[test]
672 fn load_missing_returns_none() {
673 let p = std::env::temp_dir().join("entracte-supporter-does-not-exist.json");
674 let _ = fs::remove_file(&p);
675 assert!(load(&p).is_none());
676 }
677
678 #[test]
679 fn delete_is_idempotent_when_missing() {
680 let p = std::env::temp_dir().join("entracte-supporter-delete-test.json");
681 let _ = fs::remove_file(&p);
682 delete(&p).unwrap();
683 }
684
685 fn unique_temp_path(tag: &str) -> PathBuf {
686 std::env::temp_dir().join(format!(
687 "entracte-supporter-{tag}-{}-{}.json",
688 std::process::id(),
689 std::time::SystemTime::now()
690 .duration_since(std::time::UNIX_EPOCH)
691 .unwrap()
692 .as_nanos()
693 ))
694 }
695
696 #[test]
697 fn is_supporter_now_false_when_no_record_on_disk() {
698 let p = unique_temp_path("isnow-missing");
699 let _ = fs::remove_file(&p);
700 assert!(!is_supporter_now(&p));
701 }
702
703 #[test]
704 fn is_supporter_now_true_for_fresh_record() {
705 let p = unique_temp_path("isnow-fresh");
706 let rec = lemonsqueezy_record(
707 "ABCD-1111-2222-3333",
708 "i-fresh",
709 Utc::now() - chrono::Duration::days(1),
710 Utc::now() - chrono::Duration::minutes(5),
711 );
712 save(&p, &rec).unwrap();
713 assert!(is_supporter_now(&p));
714 let _ = fs::remove_file(&p);
715 }
716
717 #[test]
718 fn is_supporter_now_false_for_stale_record_past_grace_window() {
719 let p = unique_temp_path("isnow-stale");
721 let rec = lemonsqueezy_record(
722 "ZZZZ-9999-8888-7777",
723 "i-stale",
724 Utc::now() - chrono::Duration::days(60),
725 Utc::now() - chrono::Duration::days(45),
726 );
727 save(&p, &rec).unwrap();
728 assert!(!is_supporter_now(&p));
729 let _ = fs::remove_file(&p);
730 }
731
732 #[test]
733 fn record_without_source_field_deserialises_as_lemonsqueezy() {
734 let now = Utc::now();
739 let body = serde_json::json!({
740 "license_key": "LEGACY-KEY",
741 "instance_id": "i-legacy",
742 "activated_at": now,
743 "last_validated_at": now,
744 });
745 let parsed: SupporterRecord = serde_json::from_value(body).unwrap();
746 assert_eq!(parsed.source, SupporterSource::LemonSqueezy);
747 }
748
749 #[test]
750 fn save_load_round_trip_preserves_manual_source() {
751 let dir = std::env::temp_dir().join(format!(
752 "entracte-supporter-manual-{}-{}",
753 std::process::id(),
754 std::time::SystemTime::now()
755 .duration_since(std::time::UNIX_EPOCH)
756 .unwrap()
757 .as_nanos()
758 ));
759 fs::create_dir_all(&dir).unwrap();
760 let p = file_path(&dir);
761 let rec = SupporterRecord {
762 license_key: "ENT1-placeholder".to_string(),
763 instance_id: String::new(),
764 activated_at: Utc::now(),
765 last_validated_at: Utc::now(),
766 source: SupporterSource::Manual,
767 signature: String::new(),
768 };
769 save(&p, &rec).unwrap();
770 let loaded = load(&p).unwrap();
771 assert_eq!(loaded.source, SupporterSource::Manual);
772 assert_eq!(loaded.license_key, rec.license_key);
773 assert!(
774 !loaded.signature.is_empty(),
775 "save() must sign manual records too"
776 );
777 fs::remove_dir_all(&dir).ok();
778 }
779
780 #[test]
781 fn record_is_active_lemonsqueezy_uses_grace_window() {
782 let now = Utc::now();
783 let fresh = lemonsqueezy_record(
784 "K",
785 "i",
786 now - chrono::Duration::days(2),
787 now - chrono::Duration::days(1),
788 );
789 assert!(record_is_active(&fresh, now));
790 let stale = lemonsqueezy_record(
791 "K",
792 "i",
793 now - chrono::Duration::days(60),
794 now - chrono::Duration::days(45),
795 );
796 assert!(!record_is_active(&stale, now));
797 }
798
799 #[test]
800 fn needs_remote_revalidation_true_for_stale_lemonsqueezy() {
801 let now = Utc::now();
802 let rec = lemonsqueezy_record(
803 "K",
804 "i",
805 now - chrono::Duration::days(2),
806 now - chrono::Duration::hours(25),
807 );
808 assert!(needs_remote_revalidation(&rec, now));
809 }
810
811 #[test]
812 fn needs_remote_revalidation_false_for_fresh_lemonsqueezy() {
813 let now = Utc::now();
814 let rec = lemonsqueezy_record(
815 "K",
816 "i",
817 now - chrono::Duration::days(1),
818 now - chrono::Duration::hours(2),
819 );
820 assert!(!needs_remote_revalidation(&rec, now));
821 }
822
823 #[test]
824 fn needs_remote_revalidation_always_false_for_manual() {
825 let now = Utc::now();
828 let rec = SupporterRecord {
829 license_key: "ENT1-irrelevant".to_string(),
830 instance_id: String::new(),
831 activated_at: now - chrono::Duration::days(365),
832 last_validated_at: now - chrono::Duration::days(365),
833 source: SupporterSource::Manual,
834 signature: String::new(),
835 };
836 assert!(!needs_remote_revalidation(&rec, now));
837 }
838
839 #[tokio::test]
840 async fn activate_with_base_rejects_empty_key() {
841 let p = unique_temp_path("activate-empty");
842 let client = reqwest::Client::new();
843 let err = activate_with_base(
844 &p,
845 &client,
846 "http://unused",
847 None,
848 " ",
849 "host",
850 Utc::now(),
851 )
852 .await
853 .unwrap_err();
854 assert!(err.contains("empty"), "got: {err}");
855 assert!(!p.exists(), "no record should have been written");
856 }
857
858 #[tokio::test]
859 async fn activate_with_base_lemon_squeezy_path_persists_record() {
860 let mut server = mockito::Server::new_async().await;
861 let _m = server
862 .mock("POST", "/licenses/activate")
863 .with_status(200)
864 .with_header("content-type", "application/json")
865 .with_body(r#"{"activated": true, "instance": {"id": "inst-77"}}"#)
866 .create_async()
867 .await;
868 let p = unique_temp_path("activate-ls");
869 let client = reqwest::Client::new();
870 let now = Utc::now();
871 let rec = activate_with_base(&p, &client, &server.url(), None, "LS-KEY", "host-a", now)
872 .await
873 .unwrap();
874 assert_eq!(rec.source, SupporterSource::LemonSqueezy);
875 assert_eq!(rec.instance_id, "inst-77");
876 assert_eq!(rec.license_key, "LS-KEY");
877 let on_disk = load(&p).unwrap();
878 assert_eq!(on_disk.license_key, rec.license_key);
879 assert_eq!(on_disk.instance_id, rec.instance_id);
880 assert_eq!(on_disk.activated_at, rec.activated_at);
881 assert_eq!(on_disk.source, rec.source);
882 assert!(!on_disk.signature.is_empty());
883 let _ = fs::remove_file(&p);
884 }
885
886 #[tokio::test]
887 async fn activate_with_base_lemon_squeezy_failure_does_not_persist() {
888 let mut server = mockito::Server::new_async().await;
889 let _m = server
890 .mock("POST", "/licenses/activate")
891 .with_status(200)
892 .with_body(r#"{"activated": false, "error": "license_key not found"}"#)
893 .create_async()
894 .await;
895 let p = unique_temp_path("activate-ls-fail");
896 let client = reqwest::Client::new();
897 let err = activate_with_base(
898 &p,
899 &client,
900 &server.url(),
901 None,
902 "BAD",
903 "host-b",
904 Utc::now(),
905 )
906 .await
907 .unwrap_err();
908 assert!(err.contains("not found"), "got: {err}");
909 assert!(!p.exists(), "no record should have been written");
910 }
911
912 #[tokio::test]
913 async fn activate_with_base_manual_path_persists_record_with_injected_verifier() {
914 let sk = fresh_signing_key();
920 let vk = sk.verifying_key();
921 let license = manual::ManualLicense {
922 name: "Tester".to_string(),
923 issued_at: Utc::now(),
924 };
925 let token = manual::sign(&sk, &license).unwrap();
926 let p = unique_temp_path("activate-manual-ok");
927 let client = reqwest::Client::new();
928 let now = Utc::now();
929 let rec = activate_with_base(
930 &p,
931 &client,
932 "http://unused",
933 Some(&vk),
934 &token,
935 "host-c",
936 now,
937 )
938 .await
939 .unwrap();
940 assert_eq!(rec.source, SupporterSource::Manual);
941 assert_eq!(rec.instance_id, "");
942 assert_eq!(rec.license_key, token);
943 assert_eq!(rec.activated_at, now);
944 let on_disk = load(&p).unwrap();
945 assert_eq!(on_disk.license_key, rec.license_key);
946 assert_eq!(on_disk.source, SupporterSource::Manual);
947 assert!(!on_disk.signature.is_empty());
948 let _ = fs::remove_file(&p);
949 }
950
951 #[tokio::test]
952 async fn activate_with_base_manual_path_rejects_tampered_token() {
953 let sk = fresh_signing_key();
954 let vk = sk.verifying_key();
955 let license = manual::ManualLicense {
956 name: "Tester".to_string(),
957 issued_at: Utc::now(),
958 };
959 let mut token = manual::sign(&sk, &license).unwrap();
960 token.push('!');
961 let p = unique_temp_path("activate-manual-tamper");
962 let client = reqwest::Client::new();
963 let err = activate_with_base(
964 &p,
965 &client,
966 "http://unused",
967 Some(&vk),
968 &token,
969 "host-d",
970 Utc::now(),
971 )
972 .await
973 .unwrap_err();
974 assert!(!err.is_empty(), "expected verification failure");
975 assert!(!p.exists(), "no record should have been written");
976 }
977
978 #[test]
979 fn record_is_active_manual_requires_valid_signature() {
980 let sk = fresh_signing_key();
981 let license = manual::ManualLicense {
982 name: "Contributor".to_string(),
983 issued_at: Utc::now(),
984 };
985 let token = manual::sign(&sk, &license).unwrap();
986 let now = Utc::now();
987 let rec = SupporterRecord {
988 license_key: token,
989 instance_id: String::new(),
990 activated_at: now - chrono::Duration::days(730),
991 last_validated_at: now - chrono::Duration::days(365),
992 source: SupporterSource::Manual,
993 signature: String::new(),
994 };
995 assert!(!record_is_active(&rec, Utc::now()));
1001
1002 let mut bad = rec.clone();
1004 bad.license_key.push('!');
1005 assert!(!record_is_active(&bad, Utc::now()));
1006 }
1007
1008 #[tokio::test]
1011 async fn activate_remote_returns_instance_id_on_success() {
1012 let mut server = mockito::Server::new_async().await;
1013 let _m = server
1014 .mock("POST", "/licenses/activate")
1015 .with_status(200)
1016 .with_header("content-type", "application/json")
1017 .with_body(r#"{"activated": true, "instance": {"id": "inst-42"}}"#)
1018 .create_async()
1019 .await;
1020 let client = reqwest::Client::new();
1021 let got = activate_remote_at(&client, &server.url(), "KEY", "laptop")
1022 .await
1023 .unwrap();
1024 assert_eq!(got, "inst-42");
1025 }
1026
1027 #[tokio::test]
1028 async fn activate_remote_surfaces_server_error_message() {
1029 let mut server = mockito::Server::new_async().await;
1030 let _m = server
1031 .mock("POST", "/licenses/activate")
1032 .with_status(200)
1033 .with_body(r#"{"activated": false, "error": "key revoked"}"#)
1034 .create_async()
1035 .await;
1036 let client = reqwest::Client::new();
1037 let err = activate_remote_at(&client, &server.url(), "KEY", "laptop")
1038 .await
1039 .unwrap_err();
1040 assert!(err.contains("key revoked"));
1041 }
1042
1043 #[tokio::test]
1044 async fn activate_remote_errors_when_server_omits_instance() {
1045 let mut server = mockito::Server::new_async().await;
1048 let _m = server
1049 .mock("POST", "/licenses/activate")
1050 .with_status(200)
1051 .with_body(r#"{"activated": true}"#)
1052 .create_async()
1053 .await;
1054 let client = reqwest::Client::new();
1055 let err = activate_remote_at(&client, &server.url(), "KEY", "laptop")
1056 .await
1057 .unwrap_err();
1058 assert!(err.contains("activated=true"));
1059 }
1060
1061 #[tokio::test]
1062 async fn activate_remote_errors_on_malformed_json() {
1063 let mut server = mockito::Server::new_async().await;
1064 let _m = server
1065 .mock("POST", "/licenses/activate")
1066 .with_status(200)
1067 .with_body("not json")
1068 .create_async()
1069 .await;
1070 let client = reqwest::Client::new();
1071 let err = activate_remote_at(&client, &server.url(), "KEY", "laptop")
1072 .await
1073 .unwrap_err();
1074 assert!(err.contains("invalid response"));
1075 }
1076
1077 #[tokio::test]
1078 async fn validate_remote_returns_valid_flag() {
1079 let mut server = mockito::Server::new_async().await;
1080 let _m = server
1081 .mock("POST", "/licenses/validate")
1082 .with_status(200)
1083 .with_body(r#"{"valid": true}"#)
1084 .create_async()
1085 .await;
1086 let client = reqwest::Client::new();
1087 let got = validate_remote_at(&client, &server.url(), "KEY", "inst-1")
1088 .await
1089 .unwrap();
1090 assert!(got);
1091 }
1092
1093 #[tokio::test]
1094 async fn validate_remote_surfaces_error_message() {
1095 let mut server = mockito::Server::new_async().await;
1096 let _m = server
1097 .mock("POST", "/licenses/validate")
1098 .with_status(200)
1099 .with_body(r#"{"valid": false, "error": "instance not found"}"#)
1100 .create_async()
1101 .await;
1102 let client = reqwest::Client::new();
1103 let err = validate_remote_at(&client, &server.url(), "KEY", "inst-1")
1104 .await
1105 .unwrap_err();
1106 assert!(err.contains("instance not found"));
1107 }
1108
1109 #[tokio::test]
1110 async fn validate_remote_returns_false_when_valid_false_and_no_error() {
1111 let mut server = mockito::Server::new_async().await;
1115 let _m = server
1116 .mock("POST", "/licenses/validate")
1117 .with_status(200)
1118 .with_body(r#"{"valid": false}"#)
1119 .create_async()
1120 .await;
1121 let client = reqwest::Client::new();
1122 let got = validate_remote_at(&client, &server.url(), "KEY", "inst-1")
1123 .await
1124 .unwrap();
1125 assert!(!got);
1126 }
1127}