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