Skip to main content

ignition_core/client/
trial.rs

1//! Trial-license capability models (04-03, RIG-02/03) — field names
2//! match the **live-captured 8.3.x bodies** (04-RESEARCH §Code Examples,
3//! re-verified live on BOTH minor versions during this plan's spike:
4//! expired shape on ign-research 8.3.6, active shape on the
5//! ignition-devops rig 8.3.3).
6//!
7//! `GET /data/api/v1/trial` answers **unauthenticated** (live-verified
8//! on both rigs, both trial states) — a fresh rig has no token yet, so
9//! the trait methods apply auth ONLY when the client carries a
10//! credential (the plan's "cred present → headers ride along
11//! harmlessly" rule; a header-less client degrades cleanly, the
12//! version-command precedent).
13//!
14//! `POST /data/api/v1/trial` is the RESET: session-cookie + CSRF (tier
15//! 1, the browser-verified mechanism — live-proven end-to-end on
16//! 8.3.3: `expired:true → false`, `trialSecondsLeft 0 → 7199`). The
17//! 2xx response body IS the fresh [`TrialWire`] (live-observed), so
18//! the reset parse reuses this model. **State gate (live-discovered):
19//! the gateway answers 403 to reset attempts on a NON-expired trial**
20//! — the action layer pre-checks expiry to keep that refusal honest.
21
22use std::collections::BTreeMap;
23
24use serde::{Deserialize, Serialize};
25
26/// GET/POST path of the trial capability.
27pub(crate) const TRIAL_PATH: &str = "/data/api/v1/trial";
28
29/// GET path of the overview banners (the trial cross-check source).
30pub(crate) const BANNERS_PATH: &str = "/data/api/v1/overview/banners";
31
32/// GET `/data/api/v1/trial` — the trial state (also the POST-reset
33/// response body). Live-captured on 8.3.6 (expired) and 8.3.3 (active):
34///
35/// ```jsonc
36/// { "licenseMode": "Trial", "trialState": "AllInDemo",
37///   "trialSecondsLeft": 0, "expired": true, "emergency": false,
38///   "emergencySecondsLeft": 0, "development": false,
39///   "developmentSecondsLeft": 0 }
40/// ```
41///
42/// `trialState` domain (83-api postman, live-matched):
43/// `AllInDemo` | `SomeInDemo` | `NoneInDemo`.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct TrialWire {
46    /// `"Trial"` / `"Licensed"` / …
47    #[serde(rename = "licenseMode", default)]
48    pub license_mode: String,
49    /// `AllInDemo` / `SomeInDemo` / `NoneInDemo`.
50    #[serde(rename = "trialState", default)]
51    pub trial_state: String,
52    /// Trial countdown in epoch **SECONDS** (0 once expired).
53    #[serde(rename = "trialSecondsLeft", default)]
54    pub trial_seconds_left: i64,
55    /// The expiry flag — [`actions::rig::trial_status`]'s primary
56    /// truth (never derive "active" from banners alone; Pitfall 7).
57    #[serde(default)]
58    pub expired: bool,
59    /// Emergency-license mode flag.
60    #[serde(default)]
61    pub emergency: bool,
62    /// Emergency countdown in epoch **SECONDS**.
63    #[serde(rename = "emergencySecondsLeft", default)]
64    pub emergency_seconds_left: i64,
65    /// Development-license mode flag.
66    #[serde(default)]
67    pub development: bool,
68    /// Development countdown in epoch **SECONDS**.
69    #[serde(rename = "developmentSecondsLeft", default)]
70    pub development_seconds_left: i64,
71    /// Unknown keys round-trip (passthrough-shaped `--json`).
72    #[serde(flatten)]
73    pub extra: BTreeMap<String, serde_json::Value>,
74}
75
76/// GET `/data/api/v1/overview/banners` — the banner set. The trial
77/// banner (`type: "trial"`) is the status cross-check: expired shows
78/// `severity:"warning"` + `expireTime:null`; active shows
79/// `severity:"info"` + `expireTime` epoch-**milliseconds**
80/// (live-captured both shapes, both rigs — Pitfall 7).
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct BannerSet {
83    /// The banners, order-field verbatim (8.3.6 serves `order: 0`,
84    /// 8.3.3 serves `order: 5` for the trial banner — both parse).
85    #[serde(default)]
86    pub banners: Vec<Banner>,
87}
88
89/// One banner.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct Banner {
92    /// Display order (not an index — values differ per version).
93    #[serde(default)]
94    pub order: i64,
95    /// `"trial"` / … (the trial cross-check keys on this).
96    #[serde(rename = "type", default)]
97    pub r#type: String,
98    /// The banner payload.
99    #[serde(default)]
100    pub data: BannerData,
101}
102
103/// `banner.data` — severity + expiry. `expireTime` is epoch
104/// **MILLISECONDS** or `null` (an expired trial shows null — code
105/// expecting a future timestamp misreads expired as active; Pitfall 7).
106#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
107pub struct BannerData {
108    /// `"info"` (active) / `"warning"` (expired) / …
109    #[serde(default)]
110    pub severity: String,
111    /// Epoch **milliseconds**, or `null` when expired/unknown.
112    #[serde(rename = "expireTime", default)]
113    pub expire_time_ms: Option<i64>,
114    /// Passthrough tooltip descriptors.
115    #[serde(rename = "toolTips", default)]
116    pub tool_tips: Vec<serde_json::Value>,
117    /// Passthrough action descriptors.
118    #[serde(default)]
119    pub actions: Vec<serde_json::Value>,
120}
121
122#[cfg(test)]
123mod tests {
124    use super::{BannerSet, TrialWire};
125
126    /// THE live-capture regression (ign-research 8.3.6, expired —
127    /// fetched unauthenticated during this plan's spike).
128    #[test]
129    fn trial_parses_the_live_expired_capture() {
130        let wire: TrialWire = serde_json::from_value(serde_json::json!({
131            "licenseMode": "Trial",
132            "trialState": "AllInDemo",
133            "trialSecondsLeft": 0,
134            "expired": true,
135            "emergency": false,
136            "emergencySecondsLeft": 0,
137            "development": false,
138            "developmentSecondsLeft": 0
139        }))
140        .expect("the live expired shape must parse");
141        assert_eq!(wire.license_mode, "Trial");
142        assert_eq!(wire.trial_state, "AllInDemo");
143        assert_eq!(wire.trial_seconds_left, 0, "epoch seconds");
144        assert!(wire.expired);
145        assert!(!wire.emergency);
146    }
147
148    /// The active-state capture (ignition-devops 8.3.3, live during the
149    /// spike): countdown non-zero, expired false.
150    #[test]
151    fn trial_parses_the_live_active_capture() {
152        let wire: TrialWire = serde_json::from_value(serde_json::json!({
153            "licenseMode": "Trial",
154            "trialState": "AllInDemo",
155            "trialSecondsLeft": 6727,
156            "expired": false,
157            "emergency": false,
158            "emergencySecondsLeft": 0,
159            "development": false,
160            "developmentSecondsLeft": 0
161        }))
162        .expect("the live active shape must parse");
163        assert!(!wire.expired);
164        assert_eq!(wire.trial_seconds_left, 6727);
165    }
166
167    /// Both banner captures: expired = warning + null expireTime
168    /// (8.3.6); active = info + epoch-ms expireTime (8.3.3, where the
169    /// trial banner rides `order: 5` — order is not an index).
170    #[test]
171    fn banners_parse_both_live_states() {
172        let expired: BannerSet = serde_json::from_value(serde_json::json!({
173            "banners": [{
174                "order": 0,
175                "type": "trial",
176                "data": { "severity": "warning", "expireTime": null,
177                          "toolTips": [], "actions": [] }
178            }]
179        }))
180        .expect("the live expired banner shape must parse");
181        let trial = &expired.banners[0];
182        assert_eq!(trial.r#type, "trial");
183        assert_eq!(trial.data.severity, "warning");
184        assert_eq!(trial.data.expire_time_ms, None, "expired = null, Pitfall 7");
185
186        let active: BannerSet = serde_json::from_value(serde_json::json!({
187            "banners": [{
188                "order": 5,
189                "type": "trial",
190                "data": { "severity": "info",
191                          "expireTime": 1787435662564i64,
192                          "toolTips": [], "actions": [] }
193            }]
194        }))
195        .expect("the live active banner shape must parse");
196        let trial = &active.banners[0];
197        assert_eq!(trial.data.severity, "info");
198        assert_eq!(
199            trial.data.expire_time_ms,
200            Some(1_787_435_662_564),
201            "epoch MILLISECONDS"
202        );
203    }
204}