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}