1use std::collections::BTreeMap;
20use std::path::Path;
21
22use anyhow::{Context, Result};
23use greentic_secrets_lib::{
24 ApplyOptions, DevStore, SecretFormat, SecretsStore, SeedDoc, SeedEntry, SeedValue, apply_seed,
25};
26use qa_spec::{FormSpec, VisibilityMode, resolve_visibility};
27use serde::{Deserialize, Serialize};
28use serde_json::{Map as JsonMap, Value};
29
30use crate::canonical_secret_uri;
31
32pub async fn persist_qa_secrets(
39 store: &DevStore,
40 env: &str,
41 tenant: &str,
42 team: Option<&str>,
43 provider_id: &str,
44 config: &Value,
45 form_spec: &FormSpec,
46) -> Result<Vec<String>> {
47 let visibility = resolve_visibility(form_spec, config, VisibilityMode::Visible);
49
50 let visible_question_ids: Vec<&str> = form_spec
51 .questions
52 .iter()
53 .filter(|q| visibility.get(&q.id).copied().unwrap_or(true))
54 .map(|q| q.id.as_str())
55 .collect();
56 if visible_question_ids.is_empty() {
57 return Ok(vec![]);
58 }
59
60 let Some(config_map) = config.as_object() else {
61 return Ok(vec![]);
62 };
63
64 let mut entries = Vec::new();
65 let mut saved_keys = Vec::new();
66
67 for &key in &visible_question_ids {
68 if let Some(value) = config_map.get(key) {
69 let text = value_to_text(value);
70 if text.is_empty() || text == "null" {
71 continue;
72 }
73 let uri = canonical_secret_uri(env, tenant, team, provider_id, key);
74 entries.push(SeedEntry {
75 uri,
76 format: SecretFormat::Text,
77 value: SeedValue::Text { text },
78 description: Some(format!("from QA setup for {provider_id}")),
79 });
80 saved_keys.push(key.to_string());
81 }
82 }
83
84 if entries.is_empty() {
85 return Ok(vec![]);
86 }
87
88 let report = apply_seed(store, &SeedDoc { entries }, ApplyOptions::default()).await;
89 if !report.failed.is_empty() {
90 return Err(anyhow::anyhow!(
91 "failed to persist {} secret(s): {:?}",
92 report.failed.len(),
93 report.failed
94 ));
95 }
96
97 Ok(saved_keys)
98}
99
100pub fn filter_secrets(config: &Value, secret_ids: &[&str]) -> Value {
102 let Some(map) = config.as_object() else {
103 return config.clone();
104 };
105 let filtered: JsonMap<String, Value> = map
106 .iter()
107 .filter(|(key, _)| !secret_ids.contains(&key.as_str()))
108 .map(|(k, v)| (k.clone(), v.clone()))
109 .collect();
110 Value::Object(filtered)
111}
112
113pub async fn persist_all_config_as_secrets(
123 bundle_root: &Path,
124 env: &str,
125 tenant: &str,
126 team: Option<&str>,
127 provider_id: &str,
128 config: &Value,
129 pack_path: Option<&Path>,
130) -> Result<Vec<String>> {
131 let Some(config_map) = config.as_object() else {
132 return Ok(vec![]);
133 };
134 if config_map.is_empty() {
135 return Ok(vec![]);
136 }
137
138 let store_path = crate::secrets::ensure_path(bundle_root)?;
139 let store = crate::secrets::open_dev_store(bundle_root)?;
140
141 let mut entries = Vec::new();
142 let mut saved_keys = Vec::new();
143
144 for (key, value) in config_map {
145 let text = value_to_text(value);
146 if text.is_empty() || text == "null" {
147 continue;
148 }
149 let uri = canonical_secret_uri(env, tenant, team, provider_id, key);
150 entries.push(SeedEntry {
151 uri,
152 format: SecretFormat::Text,
153 value: SeedValue::Text { text },
154 description: Some(format!("from setup-input for {provider_id}")),
155 });
156 saved_keys.push(key.to_string());
157 }
158
159 if let Some(pp) = pack_path {
163 seed_secret_requirement_aliases(
164 &mut entries,
165 config_map,
166 env,
167 tenant,
168 team,
169 provider_id,
170 pp,
171 );
172 }
173
174 if entries.is_empty() {
175 return Ok(vec![]);
176 }
177
178 tracing::info!(
179 provider_id,
180 env,
181 tenant,
182 team = team.unwrap_or("default"),
183 store_path = %store_path.display(),
184 entry_count = entries.len(),
185 uris = ?entries.iter().map(|e| e.uri.as_str()).collect::<Vec<_>>(),
186 "setup secrets persist: applying seed entries"
187 );
188
189 let verify_uris: Vec<String> = entries.iter().map(|e| e.uri.clone()).collect();
190 let report = apply_seed(&store, &SeedDoc { entries }, ApplyOptions::default()).await;
191 if !report.failed.is_empty() {
192 tracing::warn!(
193 provider_id,
194 env,
195 tenant,
196 team = team.unwrap_or("default"),
197 store_path = %store_path.display(),
198 failed = ?report.failed,
199 "setup secrets persist: apply_seed reported failures"
200 );
201 return Err(anyhow::anyhow!(
202 "failed to persist {} secret(s): {:?}",
203 report.failed.len(),
204 report.failed
205 ));
206 }
207
208 let mut verify_missing = Vec::new();
210 for uri in &verify_uris {
211 if store.get(uri).await.is_err() {
212 verify_missing.push(uri.clone());
213 }
214 }
215 if verify_missing.is_empty() {
216 tracing::info!(
217 provider_id,
218 env,
219 tenant,
220 team = team.unwrap_or("default"),
221 store_path = %store_path.display(),
222 verified = report.ok,
223 "setup secrets persist: post-write verification succeeded"
224 );
225 } else {
226 tracing::warn!(
227 provider_id,
228 env,
229 tenant,
230 team = team.unwrap_or("default"),
231 store_path = %store_path.display(),
232 missing_uris = ?verify_missing,
233 "setup secrets persist: post-write verification found missing entries"
234 );
235 }
236
237 Ok(saved_keys)
238}
239
240#[allow(clippy::too_many_arguments)]
247pub async fn persist_qa_results(
248 bundle_root: &Path,
249 tenant: &str,
250 team: Option<&str>,
251 provider_id: &str,
252 config: &Value,
253 form_spec: &FormSpec,
254) -> Result<Vec<String>> {
255 let env = crate::resolve_env(None);
256 let store = crate::secrets::open_dev_store(bundle_root)?;
257
258 let keys =
259 persist_qa_secrets(&store, &env, tenant, team, provider_id, config, form_spec).await?;
260
261 let bundle_id = infer_bundle_id(bundle_root);
262 if let Err(err) = emit_pack_config_input(
263 bundle_root,
264 &env,
265 &bundle_id,
266 provider_id,
267 config,
268 form_spec,
269 ) {
270 tracing::warn!(
275 provider_id,
276 env = %env,
277 bundle_id = %bundle_id,
278 bundle_root = %bundle_root.display(),
279 error = %err,
280 "pack-config-input emission failed; runtime falls back to legacy DevStore reads via C4.2 compat shim",
281 );
282 }
283
284 Ok(keys)
285}
286
287pub(crate) fn infer_bundle_id(root: &Path) -> String {
290 crate::bundle::infer_bundle_id(root)
291}
292
293pub fn oauth_authorize_stub(provider_id: &str, auth_url: Option<&str>) -> Option<String> {
298 if let Some(url) = auth_url {
299 println!("[oauth] Authorize {provider_id} at: {url}");
300 println!("[oauth] After authorizing, re-run setup to complete configuration.");
301 } else {
302 println!("[oauth] Provider {provider_id} requires OAuth authorization.");
303 println!("[oauth] OAuth integration is not yet implemented.");
304 }
305 None
306}
307
308fn seed_secret_requirement_aliases(
314 entries: &mut Vec<SeedEntry>,
315 config_map: &JsonMap<String, Value>,
316 env: &str,
317 tenant: &str,
318 team: Option<&str>,
319 provider_id: &str,
320 pack_path: &Path,
321) {
322 let reqs = match read_secret_requirements(pack_path) {
323 Ok(r) => r,
324 Err(_) => return,
325 };
326 let normalize = crate::secret_name::canonical_secret_name;
327 let existing_keys: std::collections::HashSet<String> = entries
328 .iter()
329 .filter_map(|e| e.uri.rsplit('/').next().map(String::from))
330 .collect();
331
332 for req in &reqs {
333 let canonical_req_key = normalize(&req.key);
334 if existing_keys.contains(&canonical_req_key) {
335 continue;
336 }
337 let matched_value = config_map.iter().find_map(|(cfg_key, cfg_val)| {
338 let norm_cfg = normalize(cfg_key);
339 if canonical_req_key.ends_with(&norm_cfg) {
340 let text = value_to_text(cfg_val);
341 if text.is_empty() || text == "null" {
342 None
343 } else {
344 Some(text)
345 }
346 } else {
347 None
348 }
349 });
350 if let Some(text) = matched_value {
351 let uri = canonical_secret_uri(env, tenant, team, provider_id, &canonical_req_key);
352 entries.push(SeedEntry {
353 uri,
354 format: SecretFormat::Text,
355 value: SeedValue::Text { text },
356 description: Some(format!("alias from {} for {provider_id}", req.key)),
357 });
358 }
359 }
360}
361
362fn read_secret_requirements(
363 pack_path: &Path,
364) -> Result<Vec<crate::secrets::PackSecretRequirement>> {
365 crate::secrets::load_secret_requirements_from_pack(pack_path)
366}
367
368fn value_to_text(value: &Value) -> String {
369 match value {
370 Value::String(s) => s.clone(),
371 other => other.to_string(),
372 }
373}
374
375pub const PACK_CONFIG_INPUT_SCHEMA: &str = "greentic.pack-config-input.v1";
380
381pub const PACK_CONFIG_INPUT_DIR: &str = "state/pack-configs";
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct PackConfigInput {
393 pub schema: String,
394 pub pack_id: String,
395 pub env_id: String,
396 pub bundle_id: String,
397 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
398 pub non_secret: BTreeMap<String, Value>,
399 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
403 pub secret_refs: BTreeMap<String, String>,
404}
405
406pub fn emit_pack_config_input(
421 bundle_root: &Path,
422 env_id: &str,
423 bundle_id: &str,
424 pack_id: &str,
425 config: &Value,
426 form_spec: &FormSpec,
427) -> Result<Option<std::path::PathBuf>> {
428 validate_segment("env_id", env_id)?;
429 validate_segment("bundle_id", bundle_id)?;
430 validate_segment("pack_id", pack_id)?;
431
432 let Some(config_map) = config.as_object() else {
433 return Ok(None);
434 };
435 if config_map.is_empty() {
436 return Ok(None);
437 }
438
439 let visibility = resolve_visibility(form_spec, config, VisibilityMode::Visible);
443
444 let secret_ids: std::collections::HashSet<&str> = form_spec
445 .questions
446 .iter()
447 .filter(|q| q.secret)
448 .map(|q| q.id.as_str())
449 .collect();
450
451 let visible_ids: std::collections::HashSet<&str> = form_spec
452 .questions
453 .iter()
454 .filter(|q| visibility.get(&q.id).copied().unwrap_or(true))
455 .map(|q| q.id.as_str())
456 .collect();
457
458 let mut non_secret = BTreeMap::new();
459 let mut secret_refs = BTreeMap::new();
460 for (key, value) in config_map {
461 if !visible_ids.contains(key.as_str()) {
464 continue;
465 }
466 let text = value_to_text(value);
467 if text.is_empty() || text == "null" {
468 continue;
469 }
470 if secret_ids.contains(key.as_str()) {
471 validate_segment("question.id", key)?;
472 let uri = format!("secret://{env_id}/{bundle_id}/{pack_id}/{key}");
473 secret_refs.insert(key.clone(), uri);
474 } else {
475 non_secret.insert(key.clone(), value.clone());
476 }
477 }
478
479 if non_secret.is_empty() && secret_refs.is_empty() {
480 return Ok(None);
481 }
482
483 let input = PackConfigInput {
484 schema: PACK_CONFIG_INPUT_SCHEMA.to_string(),
485 pack_id: pack_id.to_string(),
486 env_id: env_id.to_string(),
487 bundle_id: bundle_id.to_string(),
488 non_secret,
489 secret_refs,
490 };
491
492 let dir = bundle_root.join(PACK_CONFIG_INPUT_DIR);
493 std::fs::create_dir_all(&dir)
494 .with_context(|| format!("create pack-config-input dir {}", dir.display()))?;
495 let path = dir.join(format!("{pack_id}.json"));
496 let body = serde_json::to_string_pretty(&input).context("serialize pack-config-input.v1")?;
497 std::fs::write(&path, format!("{body}\n"))
498 .with_context(|| format!("write pack-config-input {}", path.display()))?;
499
500 tracing::debug!(
501 pack_id,
502 env_id,
503 bundle_id,
504 non_secret_count = input.non_secret.len(),
505 secret_ref_count = input.secret_refs.len(),
506 path = %path.display(),
507 "wizard emitted pack-config-input.v1 (C7) for deployer pickup",
508 );
509 Ok(Some(path))
510}
511
512fn validate_segment(label: &str, value: &str) -> Result<()> {
516 if value.is_empty() {
517 anyhow::bail!("{label} must not be empty for pack-config-input emission");
518 }
519 if value.contains('/') {
520 anyhow::bail!(
521 "{label} `{value}` contains '/' which would corrupt the pack-config-input layout"
522 );
523 }
524 if value == "." || value == ".." {
525 anyhow::bail!(
526 "{label} `{value}` is a relative path component and would corrupt the pack-config-input layout"
527 );
528 }
529 Ok(())
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use crate::secrets::open_dev_store;
536 use greentic_secrets_lib::SecretsStore;
537 use qa_spec::{QuestionSpec, QuestionType};
538 use serde_json::json;
539 use std::io::Write;
540 use std::path::Path;
541 use zip::write::SimpleFileOptions;
542
543 fn make_form_spec(questions: Vec<QuestionSpec>) -> FormSpec {
544 FormSpec {
545 id: "test".into(),
546 title: "Test".into(),
547 version: "1.0.0".into(),
548 description: None,
549 presentation: None,
550 progress_policy: None,
551 secrets_policy: None,
552 store: vec![],
553 validations: vec![],
554 includes: vec![],
555 questions,
556 }
557 }
558
559 fn question(id: &str, secret: bool) -> QuestionSpec {
560 QuestionSpec {
561 id: id.into(),
562 kind: QuestionType::String,
563 title: id.into(),
564 title_i18n: None,
565 description: None,
566 description_i18n: None,
567 required: false,
568 choices: None,
569 default_value: None,
570 secret,
571 visible_if: None,
572 constraint: None,
573 list: None,
574 computed: None,
575 policy: Default::default(),
576 computed_overridable: false,
577 }
578 }
579
580 #[test]
581 fn filters_out_secret_fields() {
582 let config = json!({
583 "enabled": true,
584 "bot_token": "secret123",
585 "public_url": "https://example.com"
586 });
587 let secret_ids = vec!["bot_token"];
588 let filtered = filter_secrets(&config, &secret_ids);
589 assert!(filtered.get("enabled").is_some());
590 assert!(filtered.get("public_url").is_some());
591 assert!(filtered.get("bot_token").is_none());
592 }
593
594 #[test]
595 fn no_secrets_returns_full_config() {
596 let config = json!({"enabled": true, "url": "https://example.com"});
597 let filtered = filter_secrets(&config, &[]);
598 assert_eq!(filtered, config);
599 }
600
601 #[test]
602 fn identifies_secret_questions() {
603 let spec = make_form_spec(vec![
604 question("enabled", false),
605 question("bot_token", true),
606 question("api_secret", true),
607 question("url", false),
608 ]);
609 let secret_ids: Vec<&str> = spec
610 .questions
611 .iter()
612 .filter(|q| q.secret)
613 .map(|q| q.id.as_str())
614 .collect();
615 assert_eq!(secret_ids, vec!["bot_token", "api_secret"]);
616 }
617
618 fn write_pack_with_secret_requirements(path: &Path, req_json: &str) {
619 let file = std::fs::File::create(path).expect("create pack");
620 let mut zip = zip::ZipWriter::new(file);
621 zip.start_file(
622 "assets/secret-requirements.json",
623 SimpleFileOptions::default(),
624 )
625 .expect("start entry");
626 zip.write_all(req_json.as_bytes()).expect("write reqs");
627 zip.finish().expect("finish zip");
628 }
629
630 #[tokio::test]
631 async fn persist_qa_secrets_persists_visible_non_empty_values() {
632 let temp = tempfile::tempdir().expect("tempdir");
633 let store = open_dev_store(temp.path()).expect("open dev store");
634 let spec = make_form_spec(vec![question("token", true), question("enabled", false)]);
635 let config = json!({
636 "token": "abc123",
637 "enabled": true,
638 "ignored": "not-in-form",
639 "empty": ""
640 });
641
642 let saved = persist_qa_secrets(
643 &store,
644 "dev",
645 "tenant-a",
646 Some("core"),
647 "messaging-telegram",
648 &config,
649 &spec,
650 )
651 .await
652 .expect("persist");
653 assert_eq!(saved, vec!["token".to_string(), "enabled".to_string()]);
654
655 let token_uri = crate::canonical_secret_uri(
656 "dev",
657 "tenant-a",
658 Some("core"),
659 "messaging-telegram",
660 "token",
661 );
662 let enabled_uri = crate::canonical_secret_uri(
663 "dev",
664 "tenant-a",
665 Some("core"),
666 "messaging-telegram",
667 "enabled",
668 );
669 let token_value =
670 String::from_utf8(store.get(&token_uri).await.expect("token")).expect("token utf8");
671 let enabled_value = String::from_utf8(store.get(&enabled_uri).await.expect("enabled"))
672 .expect("enabled utf8");
673 assert_eq!(token_value, "abc123");
674 assert_eq!(enabled_value, "true");
675 }
676
677 #[tokio::test]
678 async fn persist_all_config_as_secrets_seeds_aliases_from_requirements() {
679 let temp = tempfile::tempdir().expect("tempdir");
680 let bundle_root = temp.path();
681 let pack = bundle_root.join("messaging-webex.gtpack");
682 write_pack_with_secret_requirements(&pack, r#"[{"key":"WEBEX_BOT_TOKEN"}]"#);
683
684 let config = json!({
685 "bot_token": "xyz"
686 });
687 let saved = persist_all_config_as_secrets(
688 bundle_root,
689 "dev",
690 "tenant-a",
691 Some("core"),
692 "messaging-webex",
693 &config,
694 Some(&pack),
695 )
696 .await
697 .expect("persist all");
698 assert_eq!(saved, vec!["bot_token".to_string()]);
699
700 let store = open_dev_store(bundle_root).expect("open store");
701 let base_uri = crate::canonical_secret_uri(
702 "dev",
703 "tenant-a",
704 Some("core"),
705 "messaging-webex",
706 "bot_token",
707 );
708 let alias_uri = crate::canonical_secret_uri(
709 "dev",
710 "tenant-a",
711 Some("core"),
712 "messaging-webex",
713 "WEBEX_BOT_TOKEN",
714 );
715 let base_value =
716 String::from_utf8(store.get(&base_uri).await.expect("base")).expect("base utf8");
717 let alias_value =
718 String::from_utf8(store.get(&alias_uri).await.expect("alias")).expect("alias utf8");
719 assert_eq!(base_value, "xyz");
720 assert_eq!(alias_value, "xyz");
721 }
722
723 #[test]
724 fn oauth_authorize_stub_returns_none() {
725 assert!(
726 oauth_authorize_stub("messaging-slack", Some("https://auth.example.com")).is_none()
727 );
728 assert!(oauth_authorize_stub("messaging-slack", None).is_none());
729 }
730
731 #[test]
736 fn emit_pack_config_input_splits_secret_vs_non_secret() {
737 let tmp = tempfile::TempDir::new().expect("tempdir");
738 let root = tmp.path();
739 let form = make_form_spec(vec![
740 question("enabled", false),
741 question("bot_token", true),
742 question("public_url", false),
743 ]);
744 let config = json!({
745 "enabled": true,
746 "bot_token": "shhh",
747 "public_url": "https://example.com",
748 });
749 let path =
750 emit_pack_config_input(root, "local", "test-bundle", "provider-a", &config, &form)
751 .expect("emit")
752 .expect("path");
753 assert!(path.exists());
754 let bytes = std::fs::read(&path).expect("read");
755 let parsed: PackConfigInput = serde_json::from_slice(&bytes).expect("parse");
756 assert_eq!(parsed.schema, PACK_CONFIG_INPUT_SCHEMA);
757 assert_eq!(parsed.pack_id, "provider-a");
758 assert_eq!(parsed.env_id, "local");
759 assert_eq!(parsed.bundle_id, "test-bundle");
760 assert_eq!(
761 parsed.non_secret.get("enabled"),
762 Some(&Value::Bool(true)),
763 "non-secret inline"
764 );
765 assert_eq!(
766 parsed.non_secret.get("public_url"),
767 Some(&Value::String("https://example.com".into())),
768 );
769 assert!(
770 !parsed.non_secret.contains_key("bot_token"),
771 "secret must not be in non_secret"
772 );
773 assert_eq!(
774 parsed.secret_refs.get("bot_token").map(String::as_str),
775 Some("secret://local/test-bundle/provider-a/bot_token"),
776 "secret recorded as URI ref"
777 );
778 let body = String::from_utf8(bytes).expect("utf8");
780 assert!(
781 !body.contains("shhh"),
782 "plaintext secret leaked into pack-config-input: {body}"
783 );
784 }
785
786 #[test]
789 fn emit_pack_config_input_secret_refs_discriminate_on_env_id() {
790 let tmp_a = tempfile::TempDir::new().expect("tempdir-a");
791 let tmp_b = tempfile::TempDir::new().expect("tempdir-b");
792 let form = make_form_spec(vec![question("api_token", true)]);
793 let cfg = json!({"api_token": "x"});
794 let pa = emit_pack_config_input(tmp_a.path(), "local", "b", "p", &cfg, &form)
795 .expect("emit-a")
796 .expect("path-a");
797 let pb = emit_pack_config_input(tmp_b.path(), "staging", "b", "p", &cfg, &form)
798 .expect("emit-b")
799 .expect("path-b");
800 let parsed_a: PackConfigInput =
801 serde_json::from_slice(&std::fs::read(&pa).unwrap()).unwrap();
802 let parsed_b: PackConfigInput =
803 serde_json::from_slice(&std::fs::read(&pb).unwrap()).unwrap();
804 assert_eq!(
805 parsed_a.secret_refs.get("api_token").map(String::as_str),
806 Some("secret://local/b/p/api_token")
807 );
808 assert_eq!(
809 parsed_b.secret_refs.get("api_token").map(String::as_str),
810 Some("secret://staging/b/p/api_token")
811 );
812 }
813
814 #[test]
818 fn emit_pack_config_input_skips_empty_config() {
819 let tmp = tempfile::TempDir::new().expect("tempdir");
820 let root = tmp.path();
821 let form = make_form_spec(vec![question("enabled", false)]);
822 let empty = json!({});
823 assert!(
824 emit_pack_config_input(root, "local", "b", "p", &empty, &form)
825 .expect("emit")
826 .is_none()
827 );
828 assert!(!root.join(PACK_CONFIG_INPUT_DIR).exists());
829 }
830
831 #[test]
834 fn emit_pack_config_input_rejects_invalid_segments() {
835 let tmp = tempfile::TempDir::new().expect("tempdir");
836 let root = tmp.path();
837 let form = make_form_spec(vec![question("k", false)]);
838 let cfg = json!({"k": "v"});
839 assert!(
840 emit_pack_config_input(root, "", "b", "p", &cfg, &form).is_err(),
841 "empty env_id rejected"
842 );
843 assert!(
844 emit_pack_config_input(root, "local", "b", "../p", &cfg, &form).is_err(),
845 "pack_id with `/` rejected"
846 );
847 assert!(
848 emit_pack_config_input(root, "local", "b/c", "p", &cfg, &form).is_err(),
849 "bundle_id with `/` rejected"
850 );
851 assert!(
852 emit_pack_config_input(root, "local", "b", "..", &cfg, &form).is_err(),
853 "pack_id `..` rejected"
854 );
855 assert!(
856 emit_pack_config_input(root, ".", "b", "p", &cfg, &form).is_err(),
857 "env_id `.` rejected"
858 );
859 }
860
861 #[test]
864 fn emit_pack_config_input_respects_visibility() {
865 let tmp = tempfile::TempDir::new().expect("tempdir");
866 let root = tmp.path();
867 let form = make_form_spec(vec![question("mode", false), {
868 let mut q = question("advanced_url", false);
869 q.visible_if = Some(qa_spec::Expr::Eq {
870 left: Box::new(qa_spec::Expr::Answer {
871 path: "mode".into(),
872 }),
873 right: Box::new(qa_spec::Expr::Literal {
874 value: Value::String("advanced".into()),
875 }),
876 });
877 q
878 }]);
879 let config = json!({
881 "mode": "basic",
882 "advanced_url": "https://should-be-hidden.example.com",
883 });
884 let path = emit_pack_config_input(root, "local", "b", "p", &config, &form)
885 .expect("emit")
886 .expect("path");
887 let parsed: PackConfigInput =
888 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
889 assert!(
890 !parsed.non_secret.contains_key("advanced_url"),
891 "invisible question should not appear in non_secret: {parsed:?}"
892 );
893 assert_eq!(
894 parsed.non_secret.get("mode"),
895 Some(&Value::String("basic".into())),
896 );
897 }
898}