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 let entries = retain_changed_entries(store, entries).await;
85 if entries.is_empty() {
86 return Ok(saved_keys);
87 }
88
89 let report = apply_seed(store, &SeedDoc { entries }, ApplyOptions::default()).await;
90 if !report.failed.is_empty() {
91 return Err(anyhow::anyhow!(
92 "failed to persist {} secret(s): {:?}",
93 report.failed.len(),
94 report.failed
95 ));
96 }
97
98 Ok(saved_keys)
99}
100
101async fn retain_changed_entries(store: &DevStore, entries: Vec<SeedEntry>) -> Vec<SeedEntry> {
111 let mut changed = Vec::new();
112 for entry in entries {
113 let unchanged = match &entry.value {
114 SeedValue::Text { text } => store
115 .get(&entry.uri)
116 .await
117 .is_ok_and(|existing| existing == text.as_bytes()),
118 _ => false,
119 };
120 if !unchanged {
121 changed.push(entry);
122 }
123 }
124 changed
125}
126
127pub fn filter_secrets(config: &Value, secret_ids: &[&str]) -> Value {
129 let Some(map) = config.as_object() else {
130 return config.clone();
131 };
132 let filtered: JsonMap<String, Value> = map
133 .iter()
134 .filter(|(key, _)| !secret_ids.contains(&key.as_str()))
135 .map(|(k, v)| (k.clone(), v.clone()))
136 .collect();
137 Value::Object(filtered)
138}
139
140pub async fn persist_all_config_as_secrets(
150 bundle_root: &Path,
151 env: &str,
152 tenant: &str,
153 team: Option<&str>,
154 provider_id: &str,
155 config: &Value,
156 pack_path: Option<&Path>,
157) -> Result<Vec<String>> {
158 let store_path = crate::secrets::ensure_path(bundle_root)?;
159 let store = crate::secrets::open_dev_store(bundle_root)?;
160 let mut saved_keys = Vec::new();
161
162 if let Some(pp) = pack_path {
167 let generated = crate::generated_secrets::introduce_into_store(
168 &store,
169 env,
170 tenant,
171 team,
172 provider_id,
173 pp,
174 )
175 .await?;
176 if !generated.is_empty() {
177 tracing::info!(
178 provider_id,
179 introduced = ?generated,
180 "setup secrets: introduced generated secrets"
181 );
182 }
183 saved_keys.extend(generated);
184 }
185
186 let Some(config_map) = config.as_object() else {
187 return Ok(saved_keys);
188 };
189 if config_map.is_empty() {
190 return Ok(saved_keys);
191 }
192
193 let mut entries = Vec::new();
194
195 for (key, value) in config_map {
196 let text = value_to_text(value);
197 if text.is_empty() || text == "null" {
198 continue;
199 }
200 let uri = canonical_secret_uri(env, tenant, team, provider_id, key);
201 entries.push(SeedEntry {
202 uri,
203 format: SecretFormat::Text,
204 value: SeedValue::Text { text },
205 description: Some(format!("from setup-input for {provider_id}")),
206 });
207 saved_keys.push(key.to_string());
208 }
209
210 if let Some(pp) = pack_path {
214 seed_secret_requirement_aliases(
215 &mut entries,
216 config_map,
217 env,
218 tenant,
219 team,
220 provider_id,
221 pp,
222 );
223 }
224
225 let entries = retain_changed_entries(&store, entries).await;
226 if entries.is_empty() {
227 return Ok(saved_keys);
231 }
232
233 tracing::info!(
234 provider_id,
235 env,
236 tenant,
237 team = team.unwrap_or("default"),
238 store_path = %store_path.display(),
239 entry_count = entries.len(),
240 uris = ?entries.iter().map(|e| e.uri.as_str()).collect::<Vec<_>>(),
241 "setup secrets persist: applying seed entries"
242 );
243
244 let verify_uris: Vec<String> = entries.iter().map(|e| e.uri.clone()).collect();
245 let report = apply_seed(&store, &SeedDoc { entries }, ApplyOptions::default()).await;
246 if !report.failed.is_empty() {
247 tracing::warn!(
248 provider_id,
249 env,
250 tenant,
251 team = team.unwrap_or("default"),
252 store_path = %store_path.display(),
253 failed = ?report.failed,
254 "setup secrets persist: apply_seed reported failures"
255 );
256 return Err(anyhow::anyhow!(
257 "failed to persist {} secret(s): {:?}",
258 report.failed.len(),
259 report.failed
260 ));
261 }
262
263 let mut verify_missing = Vec::new();
265 for uri in &verify_uris {
266 if store.get(uri).await.is_err() {
267 verify_missing.push(uri.clone());
268 }
269 }
270 if verify_missing.is_empty() {
271 tracing::info!(
272 provider_id,
273 env,
274 tenant,
275 team = team.unwrap_or("default"),
276 store_path = %store_path.display(),
277 verified = report.ok,
278 "setup secrets persist: post-write verification succeeded"
279 );
280 } else {
281 tracing::warn!(
282 provider_id,
283 env,
284 tenant,
285 team = team.unwrap_or("default"),
286 store_path = %store_path.display(),
287 missing_uris = ?verify_missing,
288 "setup secrets persist: post-write verification found missing entries"
289 );
290 }
291
292 Ok(saved_keys)
293}
294
295#[allow(clippy::too_many_arguments)]
302pub async fn persist_qa_results(
303 bundle_root: &Path,
304 tenant: &str,
305 team: Option<&str>,
306 provider_id: &str,
307 config: &Value,
308 form_spec: &FormSpec,
309) -> Result<Vec<String>> {
310 let env = crate::resolve_env(None);
311 let store = crate::secrets::open_dev_store(bundle_root)?;
312
313 let keys =
314 persist_qa_secrets(&store, &env, tenant, team, provider_id, config, form_spec).await?;
315
316 let bundle_id = infer_bundle_id(bundle_root);
317 if let Err(err) = emit_pack_config_input(
318 bundle_root,
319 &env,
320 &bundle_id,
321 provider_id,
322 config,
323 form_spec,
324 ) {
325 tracing::warn!(
330 provider_id,
331 env = %env,
332 bundle_id = %bundle_id,
333 bundle_root = %bundle_root.display(),
334 error = %err,
335 "pack-config-input emission failed; runtime falls back to legacy DevStore reads via C4.2 compat shim",
336 );
337 }
338
339 Ok(keys)
340}
341
342pub(crate) fn infer_bundle_id(root: &Path) -> String {
345 crate::bundle::infer_bundle_id(root)
346}
347
348pub fn oauth_authorize_stub(provider_id: &str, auth_url: Option<&str>) -> Option<String> {
353 if let Some(url) = auth_url {
354 println!("[oauth] Authorize {provider_id} at: {url}");
355 println!("[oauth] After authorizing, re-run setup to complete configuration.");
356 } else {
357 println!("[oauth] Provider {provider_id} requires OAuth authorization.");
358 println!("[oauth] OAuth integration is not yet implemented.");
359 }
360 None
361}
362
363fn seed_secret_requirement_aliases(
369 entries: &mut Vec<SeedEntry>,
370 config_map: &JsonMap<String, Value>,
371 env: &str,
372 tenant: &str,
373 team: Option<&str>,
374 provider_id: &str,
375 pack_path: &Path,
376) {
377 let reqs = match read_secret_requirements(pack_path) {
378 Ok(r) => r,
379 Err(_) => return,
380 };
381 let normalize = crate::secret_name::canonical_secret_name;
382 let existing_keys: std::collections::HashSet<String> = entries
383 .iter()
384 .filter_map(|e| e.uri.rsplit('/').next().map(String::from))
385 .collect();
386
387 for req in &reqs {
388 let canonical_req_key = normalize(&req.key);
389 if existing_keys.contains(&canonical_req_key) {
390 continue;
391 }
392 let matched_value = config_map.iter().find_map(|(cfg_key, cfg_val)| {
393 let norm_cfg = normalize(cfg_key);
394 if canonical_req_key.ends_with(&norm_cfg) {
395 let text = value_to_text(cfg_val);
396 if text.is_empty() || text == "null" {
397 None
398 } else {
399 Some(text)
400 }
401 } else {
402 None
403 }
404 });
405 if let Some(text) = matched_value {
406 let uri = canonical_secret_uri(env, tenant, team, provider_id, &canonical_req_key);
407 entries.push(SeedEntry {
408 uri,
409 format: SecretFormat::Text,
410 value: SeedValue::Text { text },
411 description: Some(format!("alias from {} for {provider_id}", req.key)),
412 });
413 }
414 }
415}
416
417fn read_secret_requirements(
418 pack_path: &Path,
419) -> Result<Vec<crate::secrets::PackSecretRequirement>> {
420 crate::secrets::load_secret_requirements_from_pack(pack_path)
421}
422
423fn value_to_text(value: &Value) -> String {
424 match value {
425 Value::String(s) => s.clone(),
426 other => other.to_string(),
427 }
428}
429
430pub const PACK_CONFIG_INPUT_SCHEMA: &str = "greentic.pack-config-input.v1";
435
436pub const PACK_CONFIG_INPUT_DIR: &str = "state/pack-configs";
440
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
447pub struct PackConfigInput {
448 pub schema: String,
449 pub pack_id: String,
450 pub env_id: String,
451 pub bundle_id: String,
452 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
453 pub non_secret: BTreeMap<String, Value>,
454 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
458 pub secret_refs: BTreeMap<String, String>,
459}
460
461pub fn emit_pack_config_input(
476 bundle_root: &Path,
477 env_id: &str,
478 bundle_id: &str,
479 pack_id: &str,
480 config: &Value,
481 form_spec: &FormSpec,
482) -> Result<Option<std::path::PathBuf>> {
483 validate_segment("env_id", env_id)?;
484 validate_segment("bundle_id", bundle_id)?;
485 validate_segment("pack_id", pack_id)?;
486
487 let Some(config_map) = config.as_object() else {
488 return Ok(None);
489 };
490 if config_map.is_empty() {
491 return Ok(None);
492 }
493
494 let visibility = resolve_visibility(form_spec, config, VisibilityMode::Visible);
498
499 let secret_ids: std::collections::HashSet<&str> = form_spec
500 .questions
501 .iter()
502 .filter(|q| q.secret)
503 .map(|q| q.id.as_str())
504 .collect();
505
506 let visible_ids: std::collections::HashSet<&str> = form_spec
507 .questions
508 .iter()
509 .filter(|q| visibility.get(&q.id).copied().unwrap_or(true))
510 .map(|q| q.id.as_str())
511 .collect();
512
513 let mut non_secret = BTreeMap::new();
514 let mut secret_refs = BTreeMap::new();
515 for (key, value) in config_map {
516 if !visible_ids.contains(key.as_str()) {
519 continue;
520 }
521 let text = value_to_text(value);
522 if text.is_empty() || text == "null" {
523 continue;
524 }
525 if secret_ids.contains(key.as_str()) {
526 validate_segment("question.id", key)?;
527 let uri = format!("secret://{env_id}/{bundle_id}/{pack_id}/{key}");
528 secret_refs.insert(key.clone(), uri);
529 } else {
530 non_secret.insert(key.clone(), value.clone());
531 }
532 }
533
534 if non_secret.is_empty() && secret_refs.is_empty() {
535 return Ok(None);
536 }
537
538 let input = PackConfigInput {
539 schema: PACK_CONFIG_INPUT_SCHEMA.to_string(),
540 pack_id: pack_id.to_string(),
541 env_id: env_id.to_string(),
542 bundle_id: bundle_id.to_string(),
543 non_secret,
544 secret_refs,
545 };
546
547 let dir = bundle_root.join(PACK_CONFIG_INPUT_DIR);
548 std::fs::create_dir_all(&dir)
549 .with_context(|| format!("create pack-config-input dir {}", dir.display()))?;
550 let path = dir.join(format!("{pack_id}.json"));
551 let body = serde_json::to_string_pretty(&input).context("serialize pack-config-input.v1")?;
552 std::fs::write(&path, format!("{body}\n"))
553 .with_context(|| format!("write pack-config-input {}", path.display()))?;
554
555 tracing::debug!(
556 pack_id,
557 env_id,
558 bundle_id,
559 non_secret_count = input.non_secret.len(),
560 secret_ref_count = input.secret_refs.len(),
561 path = %path.display(),
562 "wizard emitted pack-config-input.v1 (C7) for deployer pickup",
563 );
564 Ok(Some(path))
565}
566
567fn validate_segment(label: &str, value: &str) -> Result<()> {
571 if value.is_empty() {
572 anyhow::bail!("{label} must not be empty for pack-config-input emission");
573 }
574 if value.contains('/') {
575 anyhow::bail!(
576 "{label} `{value}` contains '/' which would corrupt the pack-config-input layout"
577 );
578 }
579 if value == "." || value == ".." {
580 anyhow::bail!(
581 "{label} `{value}` is a relative path component and would corrupt the pack-config-input layout"
582 );
583 }
584 Ok(())
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use crate::secrets::open_dev_store;
591 use greentic_secrets_lib::SecretsStore;
592 use qa_spec::{QuestionSpec, QuestionType};
593 use serde_json::json;
594 use std::io::Write;
595 use std::path::Path;
596 use zip::write::SimpleFileOptions;
597
598 fn make_form_spec(questions: Vec<QuestionSpec>) -> FormSpec {
599 FormSpec {
600 id: "test".into(),
601 title: "Test".into(),
602 version: "1.0.0".into(),
603 description: None,
604 presentation: None,
605 progress_policy: None,
606 secrets_policy: None,
607 store: vec![],
608 validations: vec![],
609 includes: vec![],
610 questions,
611 }
612 }
613
614 fn question(id: &str, secret: bool) -> QuestionSpec {
615 QuestionSpec {
616 id: id.into(),
617 kind: QuestionType::String,
618 title: id.into(),
619 title_i18n: None,
620 description: None,
621 description_i18n: None,
622 required: false,
623 choices: None,
624 default_value: None,
625 secret,
626 visible_if: None,
627 constraint: None,
628 list: None,
629 computed: None,
630 policy: Default::default(),
631 computed_overridable: false,
632 }
633 }
634
635 #[test]
636 fn filters_out_secret_fields() {
637 let config = json!({
638 "enabled": true,
639 "bot_token": "secret123",
640 "public_url": "https://example.com"
641 });
642 let secret_ids = vec!["bot_token"];
643 let filtered = filter_secrets(&config, &secret_ids);
644 assert!(filtered.get("enabled").is_some());
645 assert!(filtered.get("public_url").is_some());
646 assert!(filtered.get("bot_token").is_none());
647 }
648
649 #[test]
650 fn no_secrets_returns_full_config() {
651 let config = json!({"enabled": true, "url": "https://example.com"});
652 let filtered = filter_secrets(&config, &[]);
653 assert_eq!(filtered, config);
654 }
655
656 #[test]
657 fn identifies_secret_questions() {
658 let spec = make_form_spec(vec![
659 question("enabled", false),
660 question("bot_token", true),
661 question("api_secret", true),
662 question("url", false),
663 ]);
664 let secret_ids: Vec<&str> = spec
665 .questions
666 .iter()
667 .filter(|q| q.secret)
668 .map(|q| q.id.as_str())
669 .collect();
670 assert_eq!(secret_ids, vec!["bot_token", "api_secret"]);
671 }
672
673 fn write_pack_with_secret_requirements(path: &Path, req_json: &str) {
674 let file = std::fs::File::create(path).expect("create pack");
675 let mut zip = zip::ZipWriter::new(file);
676 zip.start_file(
677 "assets/secret-requirements.json",
678 SimpleFileOptions::default(),
679 )
680 .expect("start entry");
681 zip.write_all(req_json.as_bytes()).expect("write reqs");
682 zip.finish().expect("finish zip");
683 }
684
685 #[tokio::test]
686 async fn persist_qa_secrets_persists_visible_non_empty_values() {
687 let temp = tempfile::tempdir().expect("tempdir");
688 let store = open_dev_store(temp.path()).expect("open dev store");
689 let spec = make_form_spec(vec![question("token", true), question("enabled", false)]);
690 let config = json!({
691 "token": "abc123",
692 "enabled": true,
693 "ignored": "not-in-form",
694 "empty": ""
695 });
696
697 let saved = persist_qa_secrets(
698 &store,
699 "dev",
700 "tenant-a",
701 Some("core"),
702 "messaging-telegram",
703 &config,
704 &spec,
705 )
706 .await
707 .expect("persist");
708 assert_eq!(saved, vec!["token".to_string(), "enabled".to_string()]);
709
710 let token_uri = crate::canonical_secret_uri(
711 "dev",
712 "tenant-a",
713 Some("core"),
714 "messaging-telegram",
715 "token",
716 );
717 let enabled_uri = crate::canonical_secret_uri(
718 "dev",
719 "tenant-a",
720 Some("core"),
721 "messaging-telegram",
722 "enabled",
723 );
724 let token_value =
725 String::from_utf8(store.get(&token_uri).await.expect("token")).expect("token utf8");
726 let enabled_value = String::from_utf8(store.get(&enabled_uri).await.expect("enabled"))
727 .expect("enabled utf8");
728 assert_eq!(token_value, "abc123");
729 assert_eq!(enabled_value, "true");
730 }
731
732 #[tokio::test]
733 async fn persist_all_config_as_secrets_seeds_aliases_from_requirements() {
734 let temp = tempfile::tempdir().expect("tempdir");
735 let bundle_root = temp.path();
736 let pack = bundle_root.join("messaging-webex.gtpack");
737 write_pack_with_secret_requirements(&pack, r#"[{"key":"WEBEX_BOT_TOKEN"}]"#);
738
739 let config = json!({
740 "bot_token": "xyz"
741 });
742 let saved = persist_all_config_as_secrets(
743 bundle_root,
744 "dev",
745 "tenant-a",
746 Some("core"),
747 "messaging-webex",
748 &config,
749 Some(&pack),
750 )
751 .await
752 .expect("persist all");
753 assert_eq!(saved, vec!["bot_token".to_string()]);
754
755 let store = open_dev_store(bundle_root).expect("open store");
756 let base_uri = crate::canonical_secret_uri(
757 "dev",
758 "tenant-a",
759 Some("core"),
760 "messaging-webex",
761 "bot_token",
762 );
763 let alias_uri = crate::canonical_secret_uri(
764 "dev",
765 "tenant-a",
766 Some("core"),
767 "messaging-webex",
768 "WEBEX_BOT_TOKEN",
769 );
770 let base_value =
771 String::from_utf8(store.get(&base_uri).await.expect("base")).expect("base utf8");
772 let alias_value =
773 String::from_utf8(store.get(&alias_uri).await.expect("alias")).expect("alias utf8");
774 assert_eq!(base_value, "xyz");
775 assert_eq!(alias_value, "xyz");
776 }
777
778 #[tokio::test]
779 async fn persist_skips_store_rewrite_when_values_unchanged() {
780 let temp = tempfile::tempdir().expect("tempdir");
786 let bundle_root = temp.path();
787 let config = json!({
788 "bot_token": "xyz",
789 "enabled": "true"
790 });
791 persist_all_config_as_secrets(
792 bundle_root,
793 "dev",
794 "tenant-a",
795 Some("core"),
796 "messaging-webex",
797 &config,
798 None,
799 )
800 .await
801 .expect("first persist");
802
803 let store_path = crate::secrets::ensure_path(bundle_root).expect("store path");
804 let before = std::fs::read(&store_path).expect("read store");
805
806 persist_all_config_as_secrets(
807 bundle_root,
808 "dev",
809 "tenant-a",
810 Some("core"),
811 "messaging-webex",
812 &config,
813 None,
814 )
815 .await
816 .expect("second persist");
817 let after = std::fs::read(&store_path).expect("read store");
818 assert_eq!(
819 before, after,
820 "re-persisting unchanged values must not rewrite the store"
821 );
822
823 let changed = json!({
825 "bot_token": "rotated",
826 "enabled": "true"
827 });
828 persist_all_config_as_secrets(
829 bundle_root,
830 "dev",
831 "tenant-a",
832 Some("core"),
833 "messaging-webex",
834 &changed,
835 None,
836 )
837 .await
838 .expect("third persist");
839 let store = open_dev_store(bundle_root).expect("open store");
840 let uri = crate::canonical_secret_uri(
841 "dev",
842 "tenant-a",
843 Some("core"),
844 "messaging-webex",
845 "bot_token",
846 );
847 let value = String::from_utf8(store.get(&uri).await.expect("get")).expect("utf8");
848 assert_eq!(value, "rotated");
849 }
850
851 #[test]
852 fn oauth_authorize_stub_returns_none() {
853 assert!(
854 oauth_authorize_stub("messaging-slack", Some("https://auth.example.com")).is_none()
855 );
856 assert!(oauth_authorize_stub("messaging-slack", None).is_none());
857 }
858
859 #[test]
864 fn emit_pack_config_input_splits_secret_vs_non_secret() {
865 let tmp = tempfile::TempDir::new().expect("tempdir");
866 let root = tmp.path();
867 let form = make_form_spec(vec![
868 question("enabled", false),
869 question("bot_token", true),
870 question("public_url", false),
871 ]);
872 let config = json!({
873 "enabled": true,
874 "bot_token": "shhh",
875 "public_url": "https://example.com",
876 });
877 let path =
878 emit_pack_config_input(root, "local", "test-bundle", "provider-a", &config, &form)
879 .expect("emit")
880 .expect("path");
881 assert!(path.exists());
882 let bytes = std::fs::read(&path).expect("read");
883 let parsed: PackConfigInput = serde_json::from_slice(&bytes).expect("parse");
884 assert_eq!(parsed.schema, PACK_CONFIG_INPUT_SCHEMA);
885 assert_eq!(parsed.pack_id, "provider-a");
886 assert_eq!(parsed.env_id, "local");
887 assert_eq!(parsed.bundle_id, "test-bundle");
888 assert_eq!(
889 parsed.non_secret.get("enabled"),
890 Some(&Value::Bool(true)),
891 "non-secret inline"
892 );
893 assert_eq!(
894 parsed.non_secret.get("public_url"),
895 Some(&Value::String("https://example.com".into())),
896 );
897 assert!(
898 !parsed.non_secret.contains_key("bot_token"),
899 "secret must not be in non_secret"
900 );
901 assert_eq!(
902 parsed.secret_refs.get("bot_token").map(String::as_str),
903 Some("secret://local/test-bundle/provider-a/bot_token"),
904 "secret recorded as URI ref"
905 );
906 let body = String::from_utf8(bytes).expect("utf8");
908 assert!(
909 !body.contains("shhh"),
910 "plaintext secret leaked into pack-config-input: {body}"
911 );
912 }
913
914 #[test]
917 fn emit_pack_config_input_secret_refs_discriminate_on_env_id() {
918 let tmp_a = tempfile::TempDir::new().expect("tempdir-a");
919 let tmp_b = tempfile::TempDir::new().expect("tempdir-b");
920 let form = make_form_spec(vec![question("api_token", true)]);
921 let cfg = json!({"api_token": "x"});
922 let pa = emit_pack_config_input(tmp_a.path(), "local", "b", "p", &cfg, &form)
923 .expect("emit-a")
924 .expect("path-a");
925 let pb = emit_pack_config_input(tmp_b.path(), "staging", "b", "p", &cfg, &form)
926 .expect("emit-b")
927 .expect("path-b");
928 let parsed_a: PackConfigInput =
929 serde_json::from_slice(&std::fs::read(&pa).unwrap()).unwrap();
930 let parsed_b: PackConfigInput =
931 serde_json::from_slice(&std::fs::read(&pb).unwrap()).unwrap();
932 assert_eq!(
933 parsed_a.secret_refs.get("api_token").map(String::as_str),
934 Some("secret://local/b/p/api_token")
935 );
936 assert_eq!(
937 parsed_b.secret_refs.get("api_token").map(String::as_str),
938 Some("secret://staging/b/p/api_token")
939 );
940 }
941
942 #[test]
946 fn emit_pack_config_input_skips_empty_config() {
947 let tmp = tempfile::TempDir::new().expect("tempdir");
948 let root = tmp.path();
949 let form = make_form_spec(vec![question("enabled", false)]);
950 let empty = json!({});
951 assert!(
952 emit_pack_config_input(root, "local", "b", "p", &empty, &form)
953 .expect("emit")
954 .is_none()
955 );
956 assert!(!root.join(PACK_CONFIG_INPUT_DIR).exists());
957 }
958
959 #[test]
962 fn emit_pack_config_input_rejects_invalid_segments() {
963 let tmp = tempfile::TempDir::new().expect("tempdir");
964 let root = tmp.path();
965 let form = make_form_spec(vec![question("k", false)]);
966 let cfg = json!({"k": "v"});
967 assert!(
968 emit_pack_config_input(root, "", "b", "p", &cfg, &form).is_err(),
969 "empty env_id rejected"
970 );
971 assert!(
972 emit_pack_config_input(root, "local", "b", "../p", &cfg, &form).is_err(),
973 "pack_id with `/` rejected"
974 );
975 assert!(
976 emit_pack_config_input(root, "local", "b/c", "p", &cfg, &form).is_err(),
977 "bundle_id with `/` rejected"
978 );
979 assert!(
980 emit_pack_config_input(root, "local", "b", "..", &cfg, &form).is_err(),
981 "pack_id `..` rejected"
982 );
983 assert!(
984 emit_pack_config_input(root, ".", "b", "p", &cfg, &form).is_err(),
985 "env_id `.` rejected"
986 );
987 }
988
989 #[test]
992 fn emit_pack_config_input_respects_visibility() {
993 let tmp = tempfile::TempDir::new().expect("tempdir");
994 let root = tmp.path();
995 let form = make_form_spec(vec![question("mode", false), {
996 let mut q = question("advanced_url", false);
997 q.visible_if = Some(qa_spec::Expr::Eq {
998 left: Box::new(qa_spec::Expr::Answer {
999 path: "mode".into(),
1000 }),
1001 right: Box::new(qa_spec::Expr::Literal {
1002 value: Value::String("advanced".into()),
1003 }),
1004 });
1005 q
1006 }]);
1007 let config = json!({
1009 "mode": "basic",
1010 "advanced_url": "https://should-be-hidden.example.com",
1011 });
1012 let path = emit_pack_config_input(root, "local", "b", "p", &config, &form)
1013 .expect("emit")
1014 .expect("path");
1015 let parsed: PackConfigInput =
1016 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1017 assert!(
1018 !parsed.non_secret.contains_key("advanced_url"),
1019 "invisible question should not appear in non_secret: {parsed:?}"
1020 );
1021 assert_eq!(
1022 parsed.non_secret.get("mode"),
1023 Some(&Value::String("basic".into())),
1024 );
1025 }
1026}