Skip to main content

ignition_core/client/
adopt.rs

1//! API-key adoption wire models + session-tier calls (ADOPT-01).
2//!
3//! Every shape here is LIVE-CAPTURED on the `ignition-devops` rig,
4//! Ignition 8.3.6 (b2026042713), 2026-09-18 — browser-devtools capture
5//! while clicking Config → Security → API Keys / General Settings as
6//! `admin`, the same method as the trial route. Full bodies + the
7//! capture narrative: `.planning/research/ADOPT-RESEARCH.md`.
8//!
9//! ## The wire (live-proven end-to-end, key minted + used same second)
10//!
11//! 8.3 API keys are NOT a bespoke route family — they are resource
12//! type `ignition/api-token` on the resources API, plus ONE generate
13//! endpoint for the key material:
14//!
15//! 1. `GET  /data/api/v1/resources/list/ignition/api-token` — the
16//!    idempotent find-by-name (hash only; the plaintext key appears
17//!    NOWHERE in this list).
18//! 2. `POST /data/api/v1/api-token/generate` — `{key, hash}`; the
19//!    plaintext key is returned HERE AND ONLY HERE.
20//! 3. `POST /data/api/v1/resources/ignition/api-token` — array-of-one
21//!    resource record carrying the hash (NOT the key).
22//! 4. The security-properties singleton — the permissions wiring
23//!    (doctor's part-2 diagnosis, made writable). The GET is
24//!    `/data/api/v1/resources/singleton/ignition/security-properties`
25//!    (carrying `?defaultIfUndefined=true`); the PUT is
26//!    `/data/api/v1/resources/ignition/security-properties`.
27//!
28//! All four ride the native OIDC session cookie + `X-CSRF-Token` —
29//! the [`crate::client::idp`] machinery verbatim (tier 1). This is
30//! the bootstrap credential path: adopt runs BEFORE any API token
31//! exists, so every call is session-tier.
32//!
33//! ## Live-discovered pitfalls (both documented in ADOPT-RESEARCH)
34//!
35//! - The security-properties PUT REPLACES the whole singleton config
36//!   and carries the GET's `signature` (optimistic concurrency — it
37//!   rotates on every write). Merge read-modify-write; never blind.
38//! - `securityLevels: []` on an AnyOf permission means PUBLIC
39//!   (everyone), not "nobody" — the UI serializes a checked Public
40//!   as an empty list. [`merge_level_into`] only ever ADDS.
41//! - Security levels are leaf-path TREES: granting
42//!   `Authenticated/Roles/Administrator` serializes as ONE root
43//!   `{name:"Authenticated", children:[{Roles, children:[{Administrator}]}]}`
44//!   ([`level_tree`]).
45
46use std::collections::BTreeMap;
47
48use serde::{Deserialize, Serialize};
49use serde_json::Value;
50use serde_json::json;
51
52use crate::client::idp::GatewaySession;
53use crate::client::idp::IdpLoginFlow;
54use crate::error::CoreError;
55
56/// The key-material generator (live-captured response below).
57pub(crate) const GENERATE_PATH: &str = "/data/api/v1/api-token/generate";
58
59/// The api-token resource list (idempotent find-by-name).
60pub(crate) const API_TOKEN_LIST_PATH: &str = "/data/api/v1/resources/list/ignition/api-token";
61
62/// The api-token resource CREATE (array-of-one body).
63pub(crate) const API_TOKEN_CREATE_PATH: &str = "/data/api/v1/resources/ignition/api-token";
64
65/// The security-properties singleton READ. NOTE: the NON-singleton
66/// spelling `/resources/ignition/security-properties` is the PUT target
67/// but 404s as a GET on live 8.3.6 (live-observed 2026-09-18; the
68/// doctor capability reads the wrong path — see ADOPT-RESEARCH).
69pub(crate) const SECURITY_SINGLETON_PATH: &str =
70    "/data/api/v1/resources/singleton/ignition/security-properties";
71
72/// The security-properties singleton WRITE (array-of-one + signature).
73pub(crate) const SECURITY_PUT_PATH: &str = "/data/api/v1/resources/ignition/security-properties";
74
75/// The api-token resource type string (the create answer's
76/// `changes[].type`; asserted against the live answer in the mint).
77pub(crate) const API_TOKEN_TYPE: &str = "ignition/api-token";
78
79/// The basic-token profile type string.
80pub(crate) const BASIC_TOKEN_TYPE: &str = "basic-token";
81
82/// `POST /data/api/v1/api-token/generate` — the mint. The `key` is the
83/// plaintext token, returned here and nowhere else; `hash` is what the
84/// resource record stores. Live-captured 8.3.6:
85///
86/// ```json
87/// { "key":  "<43-char urlsafe plaintext — REDACTED, same shape>",
88///   "hash": "<43-char urlsafe stored hash — REDACTED, same shape>" }
89/// ```
90///
91/// Redaction discipline: this struct carries the plaintext between the
92/// client parse and the action's ONE exposure site (the result model +
93/// keyring write). It deliberately implements `Debug` with the fields
94/// visible — it lives core-internal for one hop; the ACTION result is
95/// the documented exposure boundary.
96#[derive(Debug, Clone, Deserialize)]
97pub struct GeneratedKeyWire {
98    /// The plaintext token (43-char urlsafe) — the only place the
99    /// gateway ever returns it.
100    #[serde(default)]
101    pub key: String,
102    /// The stored hash — rides the resource record's
103    /// `config.settings.tokenHash`.
104    #[serde(default)]
105    pub hash: String,
106}
107
108/// One `ignition/api-token` resource record — the typed subset adopt
109/// reads (find-by-name + config inspection); unknown keys round-trip.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct ApiTokenRecord {
112    /// The key's name — the find-by-name key AND half of the auth
113    /// header value (`<name>:<key>`).
114    #[serde(default)]
115    pub name: String,
116    /// Free-text description, when set.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub description: Option<String>,
119    /// Disabled keys authenticate as nothing.
120    #[serde(default)]
121    pub enabled: bool,
122    /// The token profile + hash.
123    #[serde(default)]
124    pub config: ApiTokenConfig,
125    /// Unknown keys round-trip (version, signature, attributes, …).
126    #[serde(flatten)]
127    pub extra: BTreeMap<String, Value>,
128}
129
130/// The record's `config` — `profile` (level + flags) + `settings`
131/// (the hash).
132#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
133pub struct ApiTokenConfig {
134    /// The token profile.
135    #[serde(default)]
136    pub profile: ApiTokenProfile,
137    /// The hash store.
138    #[serde(default)]
139    pub settings: ApiTokenSettings,
140}
141
142/// The record's `config.profile` — everything doctor's three-part
143/// diagnosis needs.
144#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
145pub struct ApiTokenProfile {
146    /// `"basic-token"` (the only extension point today).
147    #[serde(rename = "type", default)]
148    pub kind: String,
149    /// The granted leaf-path trees (see [`level_tree`]).
150    #[serde(rename = "securityLevels", default)]
151    pub security_levels: Vec<Value>,
152    /// The "Require secure connections" flag — MUST be false for
153    /// http gateways or every request 403s (three-part cause 3).
154    #[serde(rename = "secureChannelRequired", default)]
155    pub secure_channel_required: bool,
156    /// Creation time, epoch milliseconds.
157    #[serde(default)]
158    pub timestamp: i64,
159}
160
161/// The record's `config.settings` — the hash only, never the key.
162#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
163pub struct ApiTokenSettings {
164    /// The stored hash (`config.settings.tokenHash`).
165    #[serde(rename = "tokenHash", default)]
166    pub token_hash: String,
167}
168
169/// The shared resources-API mutation envelope (create + singleton PUT;
170/// live-captured on the create — `{success, changes[], problem}`).
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub struct ResourceMutationWire {
173    /// The gateway's verdict — `false` or a non-2xx both refuse.
174    #[serde(default)]
175    pub success: bool,
176    /// What changed (name/type/collection/newSignature).
177    #[serde(default)]
178    pub changes: Vec<ResourceChangeWire>,
179    /// Populated (non-null) when the gateway reports a partial
180    /// problem — surfaced verbatim.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub problem: Option<Value>,
183}
184
185/// One `changes[]` row.
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct ResourceChangeWire {
188    #[serde(default)]
189    pub name: String,
190    #[serde(rename = "type", default)]
191    pub kind: String,
192    #[serde(default)]
193    pub collection: String,
194    #[serde(rename = "newSignature", default)]
195    pub new_signature: String,
196}
197
198/// Render one granted level tree as its slash path
199/// (`Authenticated/Roles/Administrator`) — the human form for report
200/// rows. Walks the first-child chain (a granted tree is a single
201/// path by construction). Pure function.
202pub fn level_path_string(tree: &Value) -> String {
203    let mut segments: Vec<&str> = Vec::new();
204    let mut node = tree;
205    while let Some(name) = node.get("name").and_then(Value::as_str) {
206        segments.push(name);
207        node = node
208            .get("children")
209            .and_then(Value::as_array)
210            .and_then(|children| children.first())
211            .unwrap_or(&Value::Null);
212    }
213    segments.join("/")
214}
215
216/// The security-properties singleton READ — typed shell (the merge
217/// works at `Value` level inside `config`; the config's full shape is
218/// passthrough by design, the doctor precedent).
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
220pub struct SecuritySingletonWire {
221    /// The optimistic-concurrency token — MUST ride the PUT verbatim
222    /// or the write refuses (rotates on every write; live-observed).
223    #[serde(default)]
224    pub signature: String,
225    /// `"core"` on every capture.
226    #[serde(default = "default_collection")]
227    pub collection: String,
228    /// The whole config (`readPermissions`/`writePermissions` live
229    /// HERE, nested — not at the record's top level).
230    #[serde(default)]
231    pub config: Value,
232    /// Unknown keys round-trip.
233    #[serde(flatten)]
234    pub extra: BTreeMap<String, Value>,
235}
236
237fn default_collection() -> String {
238    "core".to_string()
239}
240
241/// Build the nested leaf-path tree for one granted level:
242/// `["Authenticated","Roles","Administrator"]` →
243/// `{name:"Authenticated", children:[{name:"Roles", children:[
244/// {name:"Administrator", children:[]}]}]}` — the live-captured
245/// encoding (the type descriptor's `defaultConfig` and every
246/// permissions list agree). Pure function; the unit test pins the
247/// captured shape verbatim.
248pub fn level_tree(path: &[&str]) -> Value {
249    match path.split_first() {
250        // The LEAF: children is the empty array (not [[]] — the
251        // recursion stops one level early by construction).
252        Some((head, [])) => json!({ "name": head, "children": [] }),
253        Some((head, tail)) => json!({
254            "name": head,
255            "children": [level_tree(tail)],
256        }),
257        // Degenerate defensive value — adopt always passes a path.
258        None => json!([]),
259    }
260}
261
262/// Build the api-token CREATE body (array-of-one) — pure function,
263/// the unit test pins it against the live-captured request VERBATIM.
264/// `timestamp_ms` is epoch milliseconds (std-only at call sites).
265pub fn build_token_create_body(
266    name: &str,
267    level: &Value,
268    token_hash: &str,
269    timestamp_ms: i64,
270) -> Value {
271    json!([{
272        "name": name,
273        "collection": "core",
274        "enabled": true,
275        "description": "",
276        "config": {
277            "profile": {
278                "securityLevels": [level],
279                "secureChannelRequired": false,
280                "type": BASIC_TOKEN_TYPE,
281                "timestamp": timestamp_ms
282            },
283            "settings": { "tokenHash": token_hash }
284        }
285    }])
286}
287
288/// Build the security-properties PUT body (array-of-one, whole-config
289/// replacement + the GET's signature) — pure function; the unit test
290/// pins the captured shape (incl. `gatewayAuditProfile: null`, which
291/// the UI sends and the GET omits).
292pub fn build_security_put_body(singleton: &SecuritySingletonWire, config: &Value) -> Value {
293    json!([{
294        "collection": singleton.collection,
295        "enabled": true,
296        "description": "",
297        "signature": singleton.signature,
298        "config": config
299    }])
300}
301
302/// Ensure a BARE-ROOT entry for the granted level's top segment
303/// exists in an AnyOf permission list inside a singleton `config` —
304/// add-if-missing, never remove, never mutate existing entries.
305/// Returns true when the config changed. Missing permissions objects
306/// are CREATED as `{"type":"AnyOf","securityLevels":[bare-root]}`.
307///
308/// WHY THE BARE ROOT and not the granted leaf-path tree
309/// (live-pinned 2026-09-18, ADOPT-RESEARCH pitfall 3): a permission
310/// entry serialized as the NESTED tree
311/// (`Authenticated>Roles>Administrator`, the UI's own spelling, with
312/// or without descriptions) does NOT admit an API token granted that
313/// exact path — 403, live-observed both ways — while a BARE-root
314/// entry (`{name:"Authenticated",children:[]}`) admits every
315/// descendant-granted token (200, live-observed). The bare root is
316/// the ANCESTOR form: strictly broader than the nested grant, so
317/// adding it can only widen admission, never narrow.
318///
319/// The `[]` case (the PUBLIC encoding — pitfall 2) gets the bare
320/// root ADDED, tightening Public down to root-admitted.
321pub fn merge_level_into(config: &mut Value, field: &str, level: &Value) -> bool {
322    let Some(incoming_root) = level.get("name").and_then(Value::as_str) else {
323        return false;
324    };
325    let Some(config_object) = config.as_object_mut() else {
326        return false;
327    };
328    let permissions = config_object
329        .entry(field)
330        .or_insert_with(|| json!({ "type": "AnyOf", "securityLevels": [] }));
331    let Some(permissions_object) = permissions.as_object_mut() else {
332        return false;
333    };
334    let levels = permissions_object
335        .entry("securityLevels")
336        .or_insert_with(|| Value::Array(Vec::new()));
337    let Some(levels_array) = levels.as_array_mut() else {
338        return false;
339    };
340    // Present = a BARE-root entry exists (children empty). A nested
341    // same-root entry does NOT count — it does not admit the token
342    // (pitfall 3), so the bare form must land beside it.
343    let has_bare_root = levels_array.iter().any(|existing| {
344        existing.get("name").and_then(Value::as_str) == Some(incoming_root)
345            && existing
346                .get("children")
347                .and_then(Value::as_array)
348                .is_some_and(|children| children.is_empty())
349    });
350    if has_bare_root {
351        return false;
352    }
353    levels_array.push(json!({ "name": incoming_root, "children": [] }));
354    true
355}
356
357/// `GET …/resources/list/ignition/api-token` on the session tier —
358/// the pre-token idempotent find. One page at `limit=100`; a gateway
359/// holding MORE tokens than that REFUSES rather than guessing (the
360/// review round's truncation trap: a key beyond the page would read
361/// as absent and the mint would collide — an honest error beats an
362/// ambiguous duplicate).
363pub async fn api_tokens_via_session(
364    flow: &IdpLoginFlow,
365    session: &GatewaySession,
366) -> Result<Vec<ApiTokenRecord>, CoreError> {
367    let value = flow
368        .session_get_json(session, API_TOKEN_LIST_PATH, &[("limit", "100")])
369        .await?;
370    let total = value
371        .get("metadata")
372        .and_then(|metadata| metadata.get("total"))
373        .and_then(Value::as_i64)
374        .unwrap_or(0);
375    let items = value
376        .get("items")
377        .cloned()
378        .ok_or_else(|| CoreError::Internal("api-token list carried no items array".into()))?;
379    let count = items.as_array().map_or(0, Vec::len) as i64;
380    if total > count {
381        return Err(CoreError::Internal(format!(
382            "this gateway holds {total} API keys — more than adopt's one-page \
383             lookup ({count} returned); delete unused keys and re-run"
384        )));
385    }
386    let records: Vec<ApiTokenRecord> = serde_json::from_value(items).map_err(|err| {
387        CoreError::Internal(format!(
388            "api-token list item did not match the record shape: {err}"
389        ))
390    })?;
391    Ok(records)
392}
393
394/// `POST /data/api/v1/api-token/generate` on the session tier —
395/// the mint (see [`GeneratedKeyWire`] for the plaintext discipline).
396pub async fn generate_api_key_via_session(
397    flow: &IdpLoginFlow,
398    session: &GatewaySession,
399) -> Result<GeneratedKeyWire, CoreError> {
400    let value = flow
401        .session_post_json(session, GENERATE_PATH, &json!({}))
402        .await?;
403    serde_json::from_value(value)
404        .map_err(|err| CoreError::Internal(format!("api-token generate answer shape: {err}")))
405}
406
407/// `POST …/resources/ignition/api-token` on the session tier — the
408/// create (array-of-one body from [`build_token_create_body`]).
409pub async fn create_api_token_via_session(
410    flow: &IdpLoginFlow,
411    session: &GatewaySession,
412    body: &Value,
413) -> Result<ResourceMutationWire, CoreError> {
414    let value = flow
415        .session_post_json(session, API_TOKEN_CREATE_PATH, body)
416        .await?;
417    serde_json::from_value(value)
418        .map_err(|err| CoreError::Internal(format!("api-token create answer shape: {err}")))
419}
420
421/// `GET …/resources/singleton/ignition/security-properties` on the
422/// session tier — the pre-token read for the merge.
423pub async fn security_properties_via_session(
424    flow: &IdpLoginFlow,
425    session: &GatewaySession,
426) -> Result<SecuritySingletonWire, CoreError> {
427    let value = flow
428        .session_get_json(
429            session,
430            SECURITY_SINGLETON_PATH,
431            &[("defaultIfUndefined", "true")],
432        )
433        .await?;
434    serde_json::from_value(value)
435        .map_err(|err| CoreError::Internal(format!("security-properties singleton shape: {err}")))
436}
437
438/// `PUT …/resources/ignition/security-properties` on the session
439/// tier — the whole-config replacement (body from
440/// [`build_security_put_body`]; the signature is the concurrency
441/// gate).
442pub async fn put_security_properties_via_session(
443    flow: &IdpLoginFlow,
444    session: &GatewaySession,
445    body: &Value,
446) -> Result<ResourceMutationWire, CoreError> {
447    let value = flow
448        .session_put_json(session, SECURITY_PUT_PATH, body)
449        .await?;
450    serde_json::from_value(value)
451        .map_err(|err| CoreError::Internal(format!("security-properties put answer shape: {err}")))
452}
453
454#[cfg(test)]
455mod tests {
456    use serde_json::Value;
457    use serde_json::json;
458
459    use super::{
460        ApiTokenRecord, GeneratedKeyWire, ResourceMutationWire, SecuritySingletonWire,
461        build_security_put_body, build_token_create_body, level_tree, merge_level_into,
462    };
463
464    /// THE live-capture regression — the generate answer's SHAPE,
465    /// from the 8.3.6 rig capture (ADOPT-RESEARCH §1a). The VALUES
466    /// are redacted same-shape synthetics: real key material never
467    /// lands in source, even disposable-rig keys (review round).
468    #[test]
469    fn generate_parses_the_live_capture() {
470        let wire: GeneratedKeyWire = serde_json::from_value(json!({
471            "key": "AAAAexample-redacted-key-43-chars-urlsafe-0",
472            "hash": "BBBBexample_redacted_hash_43_chars_urlsafe0"
473        }))
474        .expect("the live generate shape must parse");
475        assert_eq!(wire.key.len(), 43, "urlsafe plaintext, 43 chars");
476        assert_eq!(wire.hash.len(), 43, "stored hash, 43 chars");
477    }
478
479    /// The live-captured list item (trimmed to the typed fields +
480    /// one passthrough key) must parse with the level + secure flag
481    /// + hash visible (ADOPT-RESEARCH §1c).
482    #[test]
483    fn list_item_parses_the_live_capture() {
484        let record: ApiTokenRecord = serde_json::from_value(json!({
485            "type": "ignition/api-token",
486            "name": "ign-adopt-capture",
487            "description": "",
488            "enabled": true,
489            "version": 1,
490            "collection": "core",
491            "signature": "87717b87",
492            "config": {
493                "profile": {
494                    "type": "basic-token",
495                    "secureChannelRequired": false,
496                    "securityLevels": [
497                        { "name": "Authenticated", "children": [] }
498                    ],
499                    "timestamp": 1789760514446i64
500                },
501                "settings": { "tokenHash": "BBBBexample_redacted_hash_43_chars_urlsafe0" }
502            },
503            "attributes": { "uuid": "…", "enabled": true }
504        }))
505        .expect("the live list item shape must parse");
506        assert_eq!(record.name, "ign-adopt-capture");
507        assert!(record.enabled);
508        assert!(!record.config.profile.secure_channel_required);
509        assert_eq!(record.config.profile.kind, "basic-token");
510        assert_eq!(
511            record.config.settings.token_hash,
512            "BBBBexample_redacted_hash_43_chars_urlsafe0"
513        );
514        assert!(record.extra.contains_key("signature"), "passthrough rides");
515    }
516
517    /// The create body builder must reproduce the live-captured
518    /// request VERBATIM (modulo the varying timestamp) —
519    /// ADOPT-RESEARCH §1b.
520    #[test]
521    fn create_body_matches_the_live_capture() {
522        let level = level_tree(&["Authenticated"]);
523        let body = build_token_create_body(
524            "ign-adopt-capture",
525            &level,
526            "BBBBexample_redacted_hash_43_chars_urlsafe0",
527            1789760514446,
528        );
529        let captured: Value = json!([{
530            "name": "ign-adopt-capture",
531            "collection": "core",
532            "enabled": true,
533            "description": "",
534            "config": {
535                "profile": {
536                    "securityLevels": [
537                        { "name": "Authenticated",
538                          "description": "Represents a user who has been authenticated by the system.",
539                          "children": [] }
540                    ],
541                    "secureChannelRequired": false,
542                    "type": "basic-token",
543                    "timestamp": 1789760514446i64
544                },
545                "settings": { "tokenHash": "BBBBexample_redacted_hash_43_chars_urlsafe0" }
546            }
547        }]);
548        // descriptions are optional-in (the gateway echoes them out);
549        // the builder omits them by design — compare descriptionless.
550        let mut expected = captured;
551        for record in expected.as_array_mut().expect("array") {
552            record["config"]["profile"]["securityLevels"][0]
553                .as_object_mut()
554                .expect("level object")
555                .remove("description");
556        }
557        assert_eq!(body, expected, "byte-faithful modulo descriptions");
558    }
559
560    /// The create answer — `{success, changes[], problem}` — must
561    /// parse with the new signature visible (ADOPT-RESEARCH §1b).
562    #[test]
563    fn mutation_parses_the_live_create_answer() {
564        let wire: ResourceMutationWire = serde_json::from_value(json!({
565            "success": true,
566            "changes": [{
567                "name": "ign-adopt-capture",
568                "type": "ignition/api-token",
569                "collection": "core",
570                "newSignature": "87717b874ec57a83e676c7e757e5264efc20e4cdb571525470ef6c93d5736f45"
571            }],
572            "problem": null
573        }))
574        .expect("the live create answer must parse");
575        assert!(wire.success);
576        assert_eq!(wire.changes.len(), 1);
577        assert_eq!(wire.changes[0].kind, "ignition/api-token");
578        assert!(wire.problem.is_none());
579    }
580
581    /// The singleton GET shape — signature + config-nested
582    /// permissions + attributes passthrough (ADOPT-RESEARCH §2).
583    #[test]
584    fn singleton_parses_the_live_capture() {
585        let wire: SecuritySingletonWire = serde_json::from_value(json!({
586            "type": "ignition/security-properties",
587            "signature": "dee8c94600032841",
588            "collection": "core",
589            "enabled": true,
590            "config": {
591                "readPermissions": {
592                    "type": "AnyOf",
593                    "securityLevels": [ { "name": "Authenticated", "children": [] } ]
594                },
595                "writePermissions": {
596                    "type": "AnyOf",
597                    "securityLevels": [ { "name": "Authenticated", "children": [] } ]
598                }
599            },
600            "attributes": { "lastModification": { "actor": "admin" } }
601        }))
602        .expect("the live singleton shape must parse");
603        assert_eq!(wire.signature, "dee8c94600032841");
604        assert!(wire.config.get("writePermissions").is_some());
605        assert!(wire.extra.contains_key("attributes"));
606    }
607
608    /// The Administrator leaf-path tree — the defaultConfig encoding
609    /// (ADOPT-RESEARCH §1b/§2).
610    #[test]
611    fn level_tree_nests_the_administrator_path() {
612        assert_eq!(
613            level_tree(&["Authenticated", "Roles", "Administrator"]),
614            json!({
615                "name": "Authenticated",
616                "children": [{
617                    "name": "Roles",
618                    "children": [{
619                        "name": "Administrator",
620                        "children": []
621                    }]
622                }]
623            })
624        );
625        assert_eq!(
626            level_tree(&["Authenticated"]),
627            json!({ "name": "Authenticated", "children": [] })
628        );
629    }
630
631    /// The slash-path render — report rows print the full leaf path.
632    #[test]
633    fn level_path_string_walks_the_chain() {
634        use super::level_path_string;
635        assert_eq!(
636            level_path_string(&level_tree(&["Authenticated", "Roles", "Administrator"])),
637            "Authenticated/Roles/Administrator"
638        );
639        assert_eq!(
640            level_path_string(&level_tree(&["Authenticated"])),
641            "Authenticated"
642        );
643    }
644
645    /// merge: ensures a BARE-ROOT entry for the granted level's top
646    /// segment — add-if-missing, idempotent; a NESTED same-root entry
647    /// does NOT count (pitfall 3: nested entries do not admit
648    /// equal-path tokens — the bare root lands BESIDE it, the
649    /// fresh-gateway-default fix); `[]` (the PUBLIC encoding —
650    /// pitfall 2) gets the root ADDED; a missing field is created.
651    #[test]
652    fn merge_adds_missing_and_is_idempotent() {
653        let authenticated = level_tree(&["Authenticated"]);
654        let administrator = level_tree(&["Authenticated", "Roles", "Administrator"]);
655        let bare = |root: &str| json!({ "name": root, "children": [] });
656
657        // Different root → the bare root added; re-run changes nothing.
658        let mut config = json!({
659            "writePermissions": {
660                "type": "AnyOf",
661                "securityLevels": [ bare("SecurityZones") ]
662            }
663        });
664        assert!(merge_level_into(
665            &mut config,
666            "writePermissions",
667            &administrator
668        ));
669        assert!(
670            !merge_level_into(&mut config, "writePermissions", &administrator),
671            "idempotent re-run changes nothing"
672        );
673        assert_eq!(
674            config["writePermissions"]["securityLevels"][1],
675            bare("Authenticated"),
676            "the BARE root lands (pitfall 3), not the nested tree"
677        );
678
679        // Bare-root entry already present → skip.
680        let mut config = json!({
681            "writePermissions": { "type": "AnyOf", "securityLevels": [authenticated.clone()] }
682        });
683        assert!(!merge_level_into(
684            &mut config,
685            "writePermissions",
686            &administrator
687        ));
688
689        // NESTED same-root entry (the fresh-gateway default) does not
690        // admit the token — the bare root lands BESIDE it.
691        let mut config = json!({
692            "writePermissions": { "type": "AnyOf", "securityLevels": [administrator.clone()] }
693        });
694        assert!(merge_level_into(
695            &mut config,
696            "writePermissions",
697            &administrator
698        ));
699        let levels = config["writePermissions"]["securityLevels"]
700            .as_array()
701            .expect("levels");
702        assert_eq!(levels.len(), 2, "bare root added beside the nested entry");
703        assert_eq!(levels[1], bare("Authenticated"));
704
705        // Empty list (Public) → the bare root lands, tightening it.
706        let mut config = json!({
707            "writePermissions": { "type": "AnyOf", "securityLevels": [] }
708        });
709        assert!(merge_level_into(
710            &mut config,
711            "writePermissions",
712            &authenticated
713        ));
714        let levels = config["writePermissions"]["securityLevels"]
715            .as_array()
716            .expect("levels");
717        assert_eq!(levels.len(), 1);
718        assert_eq!(levels[0], bare("Authenticated"));
719
720        // Missing field entirely → created as AnyOf.
721        let mut config = json!({});
722        assert!(merge_level_into(
723            &mut config,
724            "readPermissions",
725            &authenticated
726        ));
727        assert_eq!(config["readPermissions"]["type"], "AnyOf");
728    }
729
730    /// The PUT body — array-of-one, whole config, the GET's
731    /// signature riding verbatim (ADOPT-RESEARCH §2).
732    #[test]
733    fn put_body_carries_the_signature() {
734        let singleton: SecuritySingletonWire = serde_json::from_value(json!({
735            "signature": "dee8c94600032841",
736            "collection": "core",
737            "config": { "forceIdpAuth": true }
738        }))
739        .expect("parses");
740        let mut config = singleton.config.clone();
741        merge_level_into(
742            &mut config,
743            "writePermissions",
744            &level_tree(&["Authenticated"]),
745        );
746        let body = build_security_put_body(&singleton, &config);
747        assert_eq!(body[0]["signature"], "dee8c94600032841");
748        assert_eq!(body[0]["collection"], "core");
749        assert_eq!(body[0]["config"]["forceIdpAuth"], true);
750        assert!(
751            body[0]["config"]["writePermissions"]["securityLevels"]
752                .as_array()
753                .expect("levels")
754                .len()
755                == 1
756        );
757    }
758}