1use std::collections::BTreeMap;
57
58use serde::{Deserialize, Serialize};
59use serde_json::Value;
60use serde_json::json;
61
62use crate::client::idp::GatewaySession;
63use crate::client::idp::IdpLoginFlow;
64use crate::error::CoreError;
65
66pub(crate) const GENERATE_PATH: &str = "/data/api/v1/api-token/generate";
68
69pub(crate) const API_TOKEN_LIST_PATH: &str = "/data/api/v1/resources/list/ignition/api-token";
71
72pub(crate) const API_TOKEN_CREATE_PATH: &str = "/data/api/v1/resources/ignition/api-token";
74
75pub(crate) const SECURITY_SINGLETON_PATH: &str =
80 "/data/api/v1/resources/singleton/ignition/security-properties";
81
82pub(crate) const SECURITY_PUT_PATH: &str = "/data/api/v1/resources/ignition/security-properties";
84
85pub(crate) const API_TOKEN_TYPE: &str = "ignition/api-token";
88
89pub(crate) const BASIC_TOKEN_TYPE: &str = "basic-token";
91
92#[derive(Debug, Clone, Deserialize)]
107pub struct GeneratedKeyWire {
108 #[serde(default)]
111 pub key: String,
112 #[serde(default)]
115 pub hash: String,
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct ApiTokenRecord {
122 #[serde(default)]
125 pub name: String,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub description: Option<String>,
129 #[serde(default)]
131 pub enabled: bool,
132 #[serde(default)]
134 pub config: ApiTokenConfig,
135 #[serde(flatten)]
137 pub extra: BTreeMap<String, Value>,
138}
139
140#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
143pub struct ApiTokenConfig {
144 #[serde(default)]
146 pub profile: ApiTokenProfile,
147 #[serde(default)]
149 pub settings: ApiTokenSettings,
150}
151
152#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
155pub struct ApiTokenProfile {
156 #[serde(rename = "type", default)]
158 pub kind: String,
159 #[serde(rename = "securityLevels", default)]
161 pub security_levels: Vec<Value>,
162 #[serde(rename = "secureChannelRequired", default)]
165 pub secure_channel_required: bool,
166 #[serde(default)]
168 pub timestamp: i64,
169}
170
171#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
173pub struct ApiTokenSettings {
174 #[serde(rename = "tokenHash", default)]
176 pub token_hash: String,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182pub struct ResourceMutationWire {
183 #[serde(default)]
185 pub success: bool,
186 #[serde(default)]
188 pub changes: Vec<ResourceChangeWire>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub problem: Option<Value>,
193}
194
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct ResourceChangeWire {
198 #[serde(default)]
199 pub name: String,
200 #[serde(rename = "type", default)]
201 pub kind: String,
202 #[serde(default)]
203 pub collection: String,
204 #[serde(rename = "newSignature", default)]
205 pub new_signature: String,
206}
207
208pub fn level_path_string(tree: &Value) -> String {
213 let mut segments: Vec<&str> = Vec::new();
214 let mut node = tree;
215 while let Some(name) = node.get("name").and_then(Value::as_str) {
216 segments.push(name);
217 node = node
218 .get("children")
219 .and_then(Value::as_array)
220 .and_then(|children| children.first())
221 .unwrap_or(&Value::Null);
222 }
223 segments.join("/")
224}
225
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230pub struct SecuritySingletonWire {
231 #[serde(default)]
234 pub signature: String,
235 #[serde(default = "default_collection")]
237 pub collection: String,
238 #[serde(default)]
241 pub config: Value,
242 #[serde(flatten)]
244 pub extra: BTreeMap<String, Value>,
245}
246
247fn default_collection() -> String {
248 "core".to_string()
249}
250
251pub fn level_tree(path: &[&str]) -> Value {
259 match path.split_first() {
260 Some((head, [])) => json!({ "name": head, "children": [] }),
263 Some((head, tail)) => json!({
264 "name": head,
265 "children": [level_tree(tail)],
266 }),
267 None => json!([]),
269 }
270}
271
272pub fn build_token_create_body(
279 name: &str,
280 levels: &[Value],
281 token_hash: &str,
282 timestamp_ms: i64,
283) -> Value {
284 json!([{
285 "name": name,
286 "collection": "core",
287 "enabled": true,
288 "description": "",
289 "config": {
290 "profile": {
291 "securityLevels": levels,
292 "secureChannelRequired": false,
293 "type": BASIC_TOKEN_TYPE,
294 "timestamp": timestamp_ms
295 },
296 "settings": { "tokenHash": token_hash }
297 }
298 }])
299}
300
301pub fn build_security_put_body(singleton: &SecuritySingletonWire, config: &Value) -> Value {
306 json!([{
307 "collection": singleton.collection,
308 "enabled": true,
309 "description": "",
310 "signature": singleton.signature,
311 "config": config
312 }])
313}
314
315pub fn merge_level_into(config: &mut Value, field: &str, level: &Value) -> bool {
342 let Some(incoming_root) = level.get("name").and_then(Value::as_str) else {
343 return false;
344 };
345 let Some(config_object) = config.as_object_mut() else {
346 return false;
347 };
348 let permissions = config_object
349 .entry(field)
350 .or_insert_with(|| json!({ "type": "AnyOf", "securityLevels": [] }));
351 let Some(permissions_object) = permissions.as_object_mut() else {
352 return false;
353 };
354 let levels = permissions_object
355 .entry("securityLevels")
356 .or_insert_with(|| Value::Array(Vec::new()));
357 let Some(levels_array) = levels.as_array_mut() else {
358 return false;
359 };
360 let has_bare_root = levels_array.iter().any(|existing| {
364 existing.get("name").and_then(Value::as_str) == Some(incoming_root)
365 && existing
366 .get("children")
367 .and_then(Value::as_array)
368 .is_some_and(|children| children.is_empty())
369 });
370 if has_bare_root {
371 return false;
372 }
373 levels_array.push(json!({ "name": incoming_root, "children": [] }));
374 true
375}
376
377fn level_matches(a: &Value, b: &Value) -> bool {
381 let name = |v: &Value| v.get("name").and_then(Value::as_str).map(str::to_string);
382 let children = |v: &Value| -> Vec<Value> {
383 v.get("children")
384 .and_then(Value::as_array)
385 .cloned()
386 .unwrap_or_default()
387 };
388 if name(a).is_none() || name(a) != name(b) {
389 return false;
390 }
391 let (ca, cb) = (children(a), children(b));
392 ca.len() == cb.len() && ca.iter().zip(cb.iter()).all(|(x, y)| level_matches(x, y))
393}
394
395fn is_open_entry(entry: &Value, only_authenticated: bool) -> bool {
398 let bare = entry
399 .get("children")
400 .and_then(Value::as_array)
401 .is_none_or(Vec::is_empty);
402 let name = entry.get("name").and_then(Value::as_str).unwrap_or("");
403 bare && (name.eq_ignore_ascii_case("authenticated")
404 || (!only_authenticated && name.eq_ignore_ascii_case("public")))
405}
406
407pub fn merge_write_level_into(config: &mut Value, level: &Value) -> bool {
416 let Some(config_object) = config.as_object_mut() else {
417 return false;
418 };
419 let permissions = config_object
420 .entry("writePermissions")
421 .or_insert_with(|| json!({ "type": "AnyOf", "securityLevels": [] }));
422 let Some(permissions_object) = permissions.as_object_mut() else {
423 return false;
424 };
425 let levels = permissions_object
426 .entry("securityLevels")
427 .or_insert_with(|| Value::Array(Vec::new()));
428 let Some(levels_array) = levels.as_array_mut() else {
429 return false;
430 };
431 if levels_array.iter().any(|entry| level_matches(entry, level)) {
432 return false;
433 }
434 levels_array.push(level.clone());
435 true
436}
437
438pub fn write_admits_every_key(config: &Value) -> Option<String> {
444 let levels = config
445 .get("writePermissions")
446 .and_then(|permissions| permissions.get("securityLevels"))
447 .and_then(Value::as_array);
448 let Some(levels) = levels else {
449 return Some("writePermissions is not set".to_string());
450 };
451 if levels.is_empty() {
452 return Some("writePermissions is empty, which Ignition reads as Public".to_string());
453 }
454 levels
455 .iter()
456 .find(|entry| is_open_entry(entry, false))
457 .map(|entry| {
458 let name = entry.get("name").and_then(Value::as_str).unwrap_or("");
459 format!("writePermissions holds a bare {name} entry, which every API key holds")
460 })
461}
462
463pub fn remove_bare_authenticated_from_write(config: &mut Value) -> Result<bool, String> {
470 let Some(levels) = config
471 .get_mut("writePermissions")
472 .and_then(|permissions| permissions.get_mut("securityLevels"))
473 .and_then(Value::as_array_mut)
474 else {
475 return Ok(false);
476 };
477 let kept: Vec<Value> = levels
478 .iter()
479 .filter(|entry| !is_open_entry(entry, true))
480 .cloned()
481 .collect();
482 if kept.len() == levels.len() {
483 return Ok(false);
484 }
485 if kept.is_empty() {
486 return Err(
487 "removing the bare Authenticated entry would leave writePermissions \
488 empty, which Ignition reads as Public"
489 .to_string(),
490 );
491 }
492 *levels = kept;
493 Ok(true)
494}
495
496pub async fn api_tokens_via_session(
503 flow: &IdpLoginFlow,
504 session: &GatewaySession,
505) -> Result<Vec<ApiTokenRecord>, CoreError> {
506 let value = flow
507 .session_get_json(session, API_TOKEN_LIST_PATH, &[("limit", "100")])
508 .await?;
509 let total = value
510 .get("metadata")
511 .and_then(|metadata| metadata.get("total"))
512 .and_then(Value::as_i64)
513 .unwrap_or(0);
514 let items = value
515 .get("items")
516 .cloned()
517 .ok_or_else(|| CoreError::Internal("api-token list carried no items array".into()))?;
518 let count = items.as_array().map_or(0, Vec::len) as i64;
519 if total > count {
520 return Err(CoreError::Internal(format!(
521 "this gateway holds {total} API keys — more than adopt's one-page \
522 lookup ({count} returned); delete unused keys and re-run"
523 )));
524 }
525 let records: Vec<ApiTokenRecord> = serde_json::from_value(items).map_err(|err| {
526 CoreError::Internal(format!(
527 "api-token list item did not match the record shape: {err}"
528 ))
529 })?;
530 Ok(records)
531}
532
533pub async fn generate_api_key_via_session(
536 flow: &IdpLoginFlow,
537 session: &GatewaySession,
538) -> Result<GeneratedKeyWire, CoreError> {
539 let value = flow
540 .session_post_json(session, GENERATE_PATH, &json!({}))
541 .await?;
542 serde_json::from_value(value)
543 .map_err(|err| CoreError::Internal(format!("api-token generate answer shape: {err}")))
544}
545
546pub async fn create_api_token_via_session(
549 flow: &IdpLoginFlow,
550 session: &GatewaySession,
551 body: &Value,
552) -> Result<ResourceMutationWire, CoreError> {
553 let value = flow
554 .session_post_json(session, API_TOKEN_CREATE_PATH, body)
555 .await?;
556 serde_json::from_value(value)
557 .map_err(|err| CoreError::Internal(format!("api-token create answer shape: {err}")))
558}
559
560pub async fn security_properties_via_session(
563 flow: &IdpLoginFlow,
564 session: &GatewaySession,
565) -> Result<SecuritySingletonWire, CoreError> {
566 let value = flow
567 .session_get_json(
568 session,
569 SECURITY_SINGLETON_PATH,
570 &[("defaultIfUndefined", "true")],
571 )
572 .await?;
573 serde_json::from_value(value)
574 .map_err(|err| CoreError::Internal(format!("security-properties singleton shape: {err}")))
575}
576
577pub async fn put_security_properties_via_session(
582 flow: &IdpLoginFlow,
583 session: &GatewaySession,
584 body: &Value,
585) -> Result<ResourceMutationWire, CoreError> {
586 let value = flow
587 .session_put_json(session, SECURITY_PUT_PATH, body)
588 .await?;
589 serde_json::from_value(value)
590 .map_err(|err| CoreError::Internal(format!("security-properties put answer shape: {err}")))
591}
592
593#[cfg(test)]
594mod tests {
595 use serde_json::Value;
596 use serde_json::json;
597
598 use super::{
599 ApiTokenRecord, GeneratedKeyWire, ResourceMutationWire, SecuritySingletonWire,
600 build_security_put_body, build_token_create_body, level_tree, merge_level_into,
601 merge_write_level_into, remove_bare_authenticated_from_write, write_admits_every_key,
602 };
603
604 #[test]
609 fn generate_parses_the_live_capture() {
610 let wire: GeneratedKeyWire = serde_json::from_value(json!({
611 "key": "AAAAexample-redacted-key-43-chars-urlsafe-0",
612 "hash": "BBBBexample_redacted_hash_43_chars_urlsafe0"
613 }))
614 .expect("the live generate shape must parse");
615 assert_eq!(wire.key.len(), 43, "urlsafe plaintext, 43 chars");
616 assert_eq!(wire.hash.len(), 43, "stored hash, 43 chars");
617 }
618
619 #[test]
623 fn list_item_parses_the_live_capture() {
624 let record: ApiTokenRecord = serde_json::from_value(json!({
625 "type": "ignition/api-token",
626 "name": "ign-adopt-capture",
627 "description": "",
628 "enabled": true,
629 "version": 1,
630 "collection": "core",
631 "signature": "87717b87",
632 "config": {
633 "profile": {
634 "type": "basic-token",
635 "secureChannelRequired": false,
636 "securityLevels": [
637 { "name": "Authenticated", "children": [] }
638 ],
639 "timestamp": 1789760514446i64
640 },
641 "settings": { "tokenHash": "BBBBexample_redacted_hash_43_chars_urlsafe0" }
642 },
643 "attributes": { "uuid": "…", "enabled": true }
644 }))
645 .expect("the live list item shape must parse");
646 assert_eq!(record.name, "ign-adopt-capture");
647 assert!(record.enabled);
648 assert!(!record.config.profile.secure_channel_required);
649 assert_eq!(record.config.profile.kind, "basic-token");
650 assert_eq!(
651 record.config.settings.token_hash,
652 "BBBBexample_redacted_hash_43_chars_urlsafe0"
653 );
654 assert!(record.extra.contains_key("signature"), "passthrough rides");
655 }
656
657 #[test]
661 fn create_body_matches_the_live_capture() {
662 let level = level_tree(&["Authenticated"]);
663 let body = build_token_create_body(
664 "ign-adopt-capture",
665 std::slice::from_ref(&level),
666 "BBBBexample_redacted_hash_43_chars_urlsafe0",
667 1789760514446,
668 );
669 let captured: Value = json!([{
670 "name": "ign-adopt-capture",
671 "collection": "core",
672 "enabled": true,
673 "description": "",
674 "config": {
675 "profile": {
676 "securityLevels": [
677 { "name": "Authenticated",
678 "description": "Represents a user who has been authenticated by the system.",
679 "children": [] }
680 ],
681 "secureChannelRequired": false,
682 "type": "basic-token",
683 "timestamp": 1789760514446i64
684 },
685 "settings": { "tokenHash": "BBBBexample_redacted_hash_43_chars_urlsafe0" }
686 }
687 }]);
688 let mut expected = captured;
691 for record in expected.as_array_mut().expect("array") {
692 record["config"]["profile"]["securityLevels"][0]
693 .as_object_mut()
694 .expect("level object")
695 .remove("description");
696 }
697 assert_eq!(body, expected, "byte-faithful modulo descriptions");
698 }
699
700 #[test]
703 fn mutation_parses_the_live_create_answer() {
704 let wire: ResourceMutationWire = serde_json::from_value(json!({
705 "success": true,
706 "changes": [{
707 "name": "ign-adopt-capture",
708 "type": "ignition/api-token",
709 "collection": "core",
710 "newSignature": "87717b874ec57a83e676c7e757e5264efc20e4cdb571525470ef6c93d5736f45"
711 }],
712 "problem": null
713 }))
714 .expect("the live create answer must parse");
715 assert!(wire.success);
716 assert_eq!(wire.changes.len(), 1);
717 assert_eq!(wire.changes[0].kind, "ignition/api-token");
718 assert!(wire.problem.is_none());
719 }
720
721 #[test]
724 fn singleton_parses_the_live_capture() {
725 let wire: SecuritySingletonWire = serde_json::from_value(json!({
726 "type": "ignition/security-properties",
727 "signature": "dee8c94600032841",
728 "collection": "core",
729 "enabled": true,
730 "config": {
731 "readPermissions": {
732 "type": "AnyOf",
733 "securityLevels": [ { "name": "Authenticated", "children": [] } ]
734 },
735 "writePermissions": {
736 "type": "AnyOf",
737 "securityLevels": [ { "name": "Authenticated", "children": [] } ]
738 }
739 },
740 "attributes": { "lastModification": { "actor": "admin" } }
741 }))
742 .expect("the live singleton shape must parse");
743 assert_eq!(wire.signature, "dee8c94600032841");
744 assert!(wire.config.get("writePermissions").is_some());
745 assert!(wire.extra.contains_key("attributes"));
746 }
747
748 #[test]
751 fn level_tree_nests_the_administrator_path() {
752 assert_eq!(
753 level_tree(&["Authenticated", "Roles", "Administrator"]),
754 json!({
755 "name": "Authenticated",
756 "children": [{
757 "name": "Roles",
758 "children": [{
759 "name": "Administrator",
760 "children": []
761 }]
762 }]
763 })
764 );
765 assert_eq!(
766 level_tree(&["Authenticated"]),
767 json!({ "name": "Authenticated", "children": [] })
768 );
769 }
770
771 #[test]
773 fn level_path_string_walks_the_chain() {
774 use super::level_path_string;
775 assert_eq!(
776 level_path_string(&level_tree(&["Authenticated", "Roles", "Administrator"])),
777 "Authenticated/Roles/Administrator"
778 );
779 assert_eq!(
780 level_path_string(&level_tree(&["Authenticated"])),
781 "Authenticated"
782 );
783 }
784
785 #[test]
792 fn merge_adds_missing_and_is_idempotent() {
793 let authenticated = level_tree(&["Authenticated"]);
794 let administrator = level_tree(&["Authenticated", "Roles", "Administrator"]);
795 let bare = |root: &str| json!({ "name": root, "children": [] });
796
797 let mut config = json!({
799 "writePermissions": {
800 "type": "AnyOf",
801 "securityLevels": [ bare("SecurityZones") ]
802 }
803 });
804 assert!(merge_level_into(
805 &mut config,
806 "writePermissions",
807 &administrator
808 ));
809 assert!(
810 !merge_level_into(&mut config, "writePermissions", &administrator),
811 "idempotent re-run changes nothing"
812 );
813 assert_eq!(
814 config["writePermissions"]["securityLevels"][1],
815 bare("Authenticated"),
816 "the BARE root lands (pitfall 3), not the nested tree"
817 );
818
819 let mut config = json!({
821 "writePermissions": { "type": "AnyOf", "securityLevels": [authenticated.clone()] }
822 });
823 assert!(!merge_level_into(
824 &mut config,
825 "writePermissions",
826 &administrator
827 ));
828
829 let mut config = json!({
832 "writePermissions": { "type": "AnyOf", "securityLevels": [administrator.clone()] }
833 });
834 assert!(merge_level_into(
835 &mut config,
836 "writePermissions",
837 &administrator
838 ));
839 let levels = config["writePermissions"]["securityLevels"]
840 .as_array()
841 .expect("levels");
842 assert_eq!(levels.len(), 2, "bare root added beside the nested entry");
843 assert_eq!(levels[1], bare("Authenticated"));
844
845 let mut config = json!({
847 "writePermissions": { "type": "AnyOf", "securityLevels": [] }
848 });
849 assert!(merge_level_into(
850 &mut config,
851 "writePermissions",
852 &authenticated
853 ));
854 let levels = config["writePermissions"]["securityLevels"]
855 .as_array()
856 .expect("levels");
857 assert_eq!(levels.len(), 1);
858 assert_eq!(levels[0], bare("Authenticated"));
859
860 let mut config = json!({});
862 assert!(merge_level_into(
863 &mut config,
864 "readPermissions",
865 &authenticated
866 ));
867 assert_eq!(config["readPermissions"]["type"], "AnyOf");
868 }
869
870 #[test]
873 fn put_body_carries_the_signature() {
874 let singleton: SecuritySingletonWire = serde_json::from_value(json!({
875 "signature": "dee8c94600032841",
876 "collection": "core",
877 "config": { "forceIdpAuth": true }
878 }))
879 .expect("parses");
880 let mut config = singleton.config.clone();
881 merge_level_into(
882 &mut config,
883 "writePermissions",
884 &level_tree(&["Authenticated"]),
885 );
886 let body = build_security_put_body(&singleton, &config);
887 assert_eq!(body[0]["signature"], "dee8c94600032841");
888 assert_eq!(body[0]["collection"], "core");
889 assert_eq!(body[0]["config"]["forceIdpAuth"], true);
890 assert!(
891 body[0]["config"]["writePermissions"]["securityLevels"]
892 .as_array()
893 .expect("levels")
894 .len()
895 == 1
896 );
897 }
898
899 fn commissioned() -> Value {
901 json!({
902 "writePermissions": {
903 "type": "AnyOf",
904 "securityLevels": [level_tree(&["Authenticated", "Roles", "Administrator"])]
905 }
906 })
907 }
908
909 #[test]
910 fn merge_write_level_adds_the_exact_tree_and_is_idempotent() {
911 let mut config = commissioned();
912 config["writePermissions"]["securityLevels"][0]["description"] = json!("x");
914 let admin = level_tree(&["Authenticated", "Roles", "Administrator"]);
915 assert!(!merge_write_level_into(&mut config, &admin));
916
917 let dedicated = level_tree(&["Authenticated", "IgnCliWrite"]);
918 assert!(merge_write_level_into(&mut config, &dedicated));
919 assert_eq!(config["writePermissions"]["securityLevels"][1], dedicated);
920 assert!(!merge_write_level_into(&mut config, &dedicated));
921
922 let bare_dedicated = level_tree(&["IgnCliWrite"]);
923 assert!(merge_write_level_into(&mut config, &bare_dedicated));
924 assert_eq!(
925 config["writePermissions"]["securityLevels"][2],
926 json!({ "name": "IgnCliWrite", "children": [] })
927 );
928
929 let mut empty = json!({});
931 assert!(merge_write_level_into(&mut empty, &dedicated));
932 assert_eq!(empty["writePermissions"]["type"], "AnyOf");
933 assert_eq!(
934 empty["writePermissions"]["securityLevels"],
935 json!([dedicated])
936 );
937
938 for config in [&config, &empty] {
940 let levels = config["writePermissions"]["securityLevels"]
941 .as_array()
942 .expect("levels");
943 assert!(!levels.iter().any(|entry| {
944 entry["name"] == "Authenticated"
945 && entry["children"].as_array().is_some_and(Vec::is_empty)
946 }));
947 }
948 }
949
950 #[test]
951 fn write_admits_every_key_flags_the_three_open_forms() {
952 assert!(write_admits_every_key(&json!({})).is_some());
954 assert!(
955 write_admits_every_key(&json!({
956 "writePermissions": { "type": "AnyOf", "securityLevels": [] }
957 }))
958 .is_some()
959 );
960 for name in ["Authenticated", "authenticated", "Public", "PUBLIC"] {
961 let config = json!({
962 "writePermissions": {
963 "type": "AnyOf",
964 "securityLevels": [
965 level_tree(&["Authenticated", "Roles", "Administrator"]),
966 { "name": name, "children": [] }
967 ]
968 }
969 });
970 assert!(write_admits_every_key(&config).is_some(), "{name}");
971 }
972 assert_eq!(write_admits_every_key(&commissioned()), None);
974 let mut with_dedicated = commissioned();
975 merge_write_level_into(&mut with_dedicated, &level_tree(&["IgnCliWrite"]));
976 assert_eq!(write_admits_every_key(&with_dedicated), None);
977 let nested = json!({
979 "writePermissions": {
980 "type": "AnyOf",
981 "securityLevels": [level_tree(&["Authenticated", "IgnCliWrite"])]
982 }
983 });
984 assert_eq!(write_admits_every_key(&nested), None);
985 }
986
987 #[test]
988 fn remove_bare_authenticated_keeps_the_rest_and_refuses_empty() {
989 let admin = level_tree(&["Authenticated", "Roles", "Administrator"]);
990 let bare = json!({ "name": "Authenticated", "children": [] });
991
992 let mut config = json!({
993 "readPermissions": { "type": "AnyOf", "securityLevels": [bare.clone()] },
994 "writePermissions": { "type": "AnyOf", "securityLevels": [admin.clone(), bare.clone()] }
995 });
996 assert_eq!(remove_bare_authenticated_from_write(&mut config), Ok(true));
997 assert_eq!(
998 config["writePermissions"]["securityLevels"],
999 json!([admin.clone()])
1000 );
1001 assert_eq!(
1002 config["readPermissions"]["securityLevels"],
1003 json!([bare.clone()]),
1004 "readPermissions is never touched"
1005 );
1006
1007 let mut clean = commissioned();
1009 let before = clean.clone();
1010 assert_eq!(remove_bare_authenticated_from_write(&mut clean), Ok(false));
1011 assert_eq!(clean, before);
1012
1013 let mut only = json!({
1015 "writePermissions": { "type": "AnyOf", "securityLevels": [bare] }
1016 });
1017 let before = only.clone();
1018 let err = remove_bare_authenticated_from_write(&mut only).expect_err("would empty");
1019 assert!(err.contains("Public"), "{err}");
1020 assert_eq!(only, before);
1021 }
1022
1023 #[test]
1024 fn create_body_carries_every_level() {
1025 let levels = [
1026 level_tree(&["Authenticated", "Roles", "IgnCheckRead"]),
1027 level_tree(&["Authenticated", "IgnCliWrite"]),
1028 ];
1029 let body = build_token_create_body("k", &levels, "hash", 1);
1030 let granted = body[0]["config"]["profile"]["securityLevels"]
1031 .as_array()
1032 .expect("levels");
1033 assert_eq!(granted.len(), 2);
1034 assert_eq!(granted[0], levels[0]);
1035 assert_eq!(granted[1], levels[1]);
1036 }
1037}