Skip to main content

entracte_lib/
supporter.rs

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";
19/// Maximum on-disk size for `supporter.json`. The legitimate record is
20/// well under 1 KiB; capping the read at 16 KiB defends against
21/// pathological inputs (10 GiB JSON file with one nested object) without
22/// constraining future record growth.
23const MAX_FILE_BYTES: u64 = 16 * 1024;
24/// How far into the future a timestamp may sit before we treat it as
25/// tampering rather than clock skew. 1 hour swallows reasonable NTP drift.
26const FUTURE_CLOCK_SKEW_TOLERANCE: chrono::Duration = chrono::Duration::hours(1);
27/// Tag-binding HMAC key for `supporter.json`. The threat model here is
28/// "raise the bar above text-editor tampering" — a determined user with
29/// the binary can extract this constant, so this is **not** a security
30/// boundary against a sophisticated adversary. It is, however, sufficient
31/// to detect casual JSON edits ("flip is_supporter to true") and prevents
32/// replay of records between machines (each install pins the HMAC against
33/// a per-record `activated_at` + `instance_id`).
34const 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    /// HMAC-SHA256 over the canonical encoding of the other fields,
54    /// hex-encoded. `#[serde(default)]` so records written by older
55    /// app versions still parse — `load()` treats an empty signature as
56    /// legacy and forces re-signing on next online validation.
57    #[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
100/// Reject records whose timestamps are impossible — e.g. `activated_at`
101/// later than `last_validated_at`, or either timestamp far enough into
102/// the future to suggest the user wound their clock forward to extend
103/// the offline grace window. NTP drift is accommodated by
104/// `FUTURE_CLOCK_SKEW_TOLERANCE`.
105fn 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
112/// Canonical byte encoding of the record's verifiable fields. Field
113/// ordering and length-prefixing are fixed so the same record always
114/// serialises identically — JSON's object-key order is not stable enough
115/// for HMAC input.
116fn 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); // version byte — bump if the encoding changes
125    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
159/// Read the supporter record from disk. Returns `None` if the file is
160/// missing, malformed, larger than `MAX_FILE_BYTES`, or carries a
161/// signature that doesn't verify under the current HMAC key. Records
162/// with an empty `signature` (written by app versions before HMAC
163/// binding shipped) parse but `record_is_active` will require the next
164/// online validation to re-sign before granting supporter status.
165pub 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
204/// Single-call answer to "is this install a supporter right now?".
205/// Reads the on-disk record and applies the offline grace window so
206/// callers don't have to thread `now`/grace logic of their own.
207/// Used by gated IPC paths (e.g. `custom_css`) to authorise per-call.
208pub 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
226/// Whether the daily background loop should hit the storefront for this
227/// record. Manual (community) licences carry their proof on the token
228/// itself, so the network round-trip is skipped.
229pub 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
234/// Pure activation helper: sniffs the key's source, runs the matching
235/// verification path (offline Ed25519 for `ENT1-…`, Lemon Squeezy API
236/// for everything else), and persists the resulting record to disk.
237///
238/// The Tauri command is a thin shim over this so the orchestration can
239/// be exercised end-to-end without spinning up a Tauri runtime.
240pub 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
250/// Override `manual_verifier` to bypass the embedded production public
251/// key (e.g. tests that mint a token with a freshly generated keypair).
252/// Production always passes `None`.
253pub(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
333/// HTTP-layer split for `activate_remote`: takes an explicit base URL so
334/// tests can point it at `mockito::Server` without bringing up Lemon
335/// Squeezy. The production caller hard-codes `LS_API_BASE`.
336pub(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
372/// HTTP-layer split for `validate_remote`. See `activate_remote_at`.
373pub(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        // NTP can have the validating clock a few minutes ahead of the
483        // verifying clock; we must not reject within the tolerance band.
484        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        // Attacker tries to wind last_validated_at forward to extend grace
532        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        // Hand-edit the file: bump is_supporter-relevant field, keep signature.
547        let raw = fs::read_to_string(&p).unwrap();
548        let edited = raw.replace("ORIG-KEY", "FORGED-KEY");
549        fs::write(&p, edited).unwrap();
550        // Load must reject the now-mismatched signature.
551        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        // signature is empty by construction
565        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        // A record written by an older app version has signature: "".
579        // We must still parse it so the user isn't downgraded; the next
580        // online validation will re-sign it.
581        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        // Write MAX + 1 bytes of valid-looking JSON.
599        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        // 45 days since last_validated_at — outside the 30-day offline grace.
720        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        // Records on disk before the `source` field was introduced must
735        // still parse and behave as Lemon Squeezy records (the only kind
736        // that existed). `serde(default)` carries that contract; this
737        // test fails closed if anyone removes the attribute.
738        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        // Manual records never round-trip to the storefront, even if
826        // `last_validated_at` is ancient — the signature is what matters.
827        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        // Sign with a freshly minted keypair and inject the matching
915        // verifying key — proves the manual branch a) takes precedence
916        // over the LS path (no mock stood up), b) builds a record with
917        // source: Manual + empty instance_id, and c) persists it to
918        // disk.
919        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        // No grace window applies to manual records: even with
996        // last_validated_at a year stale, the signature is what matters.
997        // The embedded pubkey is the placeholder, so `manual::verify`
998        // rejects this — `record_is_active` returns false until a real
999        // pubkey is wired in.
1000        assert!(!record_is_active(&rec, Utc::now()));
1001
1002        // Tampering with the token rejects under either pubkey.
1003        let mut bad = rec.clone();
1004        bad.license_key.push('!');
1005        assert!(!record_is_active(&bad, Utc::now()));
1006    }
1007
1008    // ----- HTTP-layer tests for activate_remote_at / validate_remote_at -----
1009
1010    #[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        // Defensive: activated=true with no instance.id is a Lemon Squeezy
1046        // response we can't act on. We must error rather than panic.
1047        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        // Some Lemon Squeezy paths return `valid: false` with no error
1112        // string (e.g. a soft-deactivated instance). The helper must
1113        // surface that as `Ok(false)`, not as a network error.
1114        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}