1use std::collections::BTreeMap;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Result, anyhow};
11use greentic_secrets_lib::core::Error as SecretError;
12use greentic_secrets_lib::{
13 ApplyOptions, DevStore, SecretFormat, SecretsStore, SeedDoc, SeedEntry, SeedValue, apply_seed,
14};
15use serde_cbor::Value as CborValue;
16use tracing::{debug, info};
17
18use crate::canonical_secret_uri;
19
20const STORE_RELATIVE: &str = ".greentic/dev/.dev.secrets.env";
23const STORE_STATE_RELATIVE: &str = ".greentic/state/dev/.dev.secrets.env";
24const OVERRIDE_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
25
26pub fn override_path() -> Option<PathBuf> {
28 std::env::var(OVERRIDE_ENV).ok().map(PathBuf::from)
29}
30
31pub fn env_store_dev_secrets_path(env: &str) -> Option<PathBuf> {
41 greentic_deployer::environment::LocalFsStore::default_root()
42 .map(|root| root.join(env).join(STORE_RELATIVE))
43}
44
45fn selected_env() -> Option<String> {
54 std::env::var("GREENTIC_ENV")
55 .ok()
56 .filter(|value| !value.trim().is_empty())
57 .map(|raw| crate::resolve_env(Some(&raw)))
58}
59
60fn write_path_for_env(bundle_root: &Path, env: &str) -> PathBuf {
63 env_store_dev_secrets_path(env).unwrap_or_else(|| bundle_root.join(STORE_RELATIVE))
64}
65
66fn bare_write_path(bundle_root: &Path) -> PathBuf {
69 selected_env()
70 .and_then(|env| env_store_dev_secrets_path(&env))
71 .unwrap_or_else(|| bundle_root.join(STORE_RELATIVE))
72}
73
74fn read_candidate_paths(bundle_root: &Path) -> Vec<PathBuf> {
77 let mut out = Vec::new();
78 if let Some(env_store) = selected_env().and_then(|env| env_store_dev_secrets_path(&env)) {
79 out.push(env_store);
80 }
81 out.push(bundle_root.join(STORE_RELATIVE));
82 out.push(bundle_root.join(STORE_STATE_RELATIVE));
83 out
84}
85
86pub fn find_existing(bundle_root: &Path) -> Option<PathBuf> {
88 find_existing_with_override(bundle_root, override_path().as_deref())
89}
90
91pub fn find_existing_with_override(
94 bundle_root: &Path,
95 override_path: Option<&Path>,
96) -> Option<PathBuf> {
97 if let Some(path) = override_path
98 && path.exists()
99 {
100 return Some(path.to_path_buf());
101 }
102 read_candidate_paths(bundle_root)
103 .into_iter()
104 .find(|candidate| candidate.exists())
105}
106
107pub fn ensure_path(bundle_root: &Path) -> Result<PathBuf> {
110 if let Some(path) = override_path() {
111 ensure_parent(&path)?;
112 return Ok(path);
113 }
114 let path = bare_write_path(bundle_root);
115 ensure_parent(&path)?;
116 Ok(path)
117}
118
119pub fn ensure_path_for_env(bundle_root: &Path, env: &str) -> Result<PathBuf> {
123 if let Some(path) = override_path() {
124 ensure_parent(&path)?;
125 return Ok(path);
126 }
127 let path = write_path_for_env(bundle_root, env);
128 ensure_parent(&path)?;
129 Ok(path)
130}
131
132pub fn default_path(bundle_root: &Path) -> PathBuf {
134 override_path().unwrap_or_else(|| bare_write_path(bundle_root))
135}
136
137fn ensure_parent(path: &Path) -> Result<()> {
138 if let Some(parent) = path.parent() {
139 std::fs::create_dir_all(parent)?;
140 }
141 Ok(())
142}
143
144pub struct SecretsSetup {
151 store: DevStore,
152 store_path: PathBuf,
153 env: String,
154 tenant: String,
155 team: Option<String>,
156 seeds: HashMap<String, SeedEntry>,
157}
158
159impl SecretsSetup {
160 pub fn new(bundle_root: &Path, env: &str, tenant: &str, team: Option<&str>) -> Result<Self> {
161 let store_path = ensure_path_for_env(bundle_root, env)?;
167 info!(path = %store_path.display(), "secrets: using dev store backend");
168 let store = DevStore::with_path(&store_path).map_err(|err| {
169 anyhow!(
170 "failed to open dev secrets store {}: {err}",
171 store_path.display()
172 )
173 })?;
174 let seeds = load_seed_entries(bundle_root)?;
175 Ok(Self {
176 store,
177 store_path,
178 env: env.to_string(),
179 tenant: tenant.to_string(),
180 team: team.map(|v| v.to_string()),
181 seeds,
182 })
183 }
184
185 pub fn store_path(&self) -> &Path {
187 &self.store_path
188 }
189
190 pub fn store(&self) -> &DevStore {
192 &self.store
193 }
194
195 pub async fn ensure_pack_secrets(&self, pack_path: &Path, provider_id: &str) -> Result<()> {
200 let keys = load_secret_keys_from_pack(pack_path)?;
201 if keys.is_empty() {
202 return Ok(());
203 }
204
205 let mut missing = Vec::new();
206 for key in keys {
207 let uri = canonical_secret_uri(
208 &self.env,
209 &self.tenant,
210 self.team.as_deref(),
211 provider_id,
212 &key,
213 );
214 debug!(uri = %uri, provider = %provider_id, key = %key, "canonicalized secret requirement");
215 match self.store.get(&uri).await {
216 Ok(_) => continue,
217 Err(SecretError::NotFound { .. }) => {
218 let source = if self.seeds.contains_key(&uri) {
219 "seeds.yaml"
220 } else {
221 "placeholder"
222 };
223 debug!(uri = %uri, source, "seeding missing secret");
224 missing.push(
225 self.seeds
226 .get(&uri)
227 .cloned()
228 .unwrap_or_else(|| placeholder_entry(uri)),
229 );
230 }
231 Err(err) => {
232 return Err(anyhow!("failed to read secret {uri}: {err}"));
233 }
234 }
235 }
236
237 if missing.is_empty() {
238 return Ok(());
239 }
240 let report = apply_seed(
241 &self.store,
242 &SeedDoc { entries: missing },
243 ApplyOptions::default(),
244 )
245 .await;
246 if !report.failed.is_empty() {
247 return Err(anyhow!("failed to seed secrets: {:?}", report.failed));
248 }
249 Ok(())
250 }
251}
252
253fn load_seed_entries(bundle_root: &Path) -> Result<HashMap<String, SeedEntry>> {
256 for candidate in seed_paths(bundle_root) {
257 if candidate.exists() {
258 let contents = std::fs::read_to_string(&candidate)?;
259 let doc: SeedDoc = serde_yaml_bw::from_str(&contents)?;
260 return Ok(doc
261 .entries
262 .into_iter()
263 .map(|entry| (entry.uri.clone(), entry))
264 .collect());
265 }
266 }
267 Ok(HashMap::new())
268}
269
270fn seed_paths(bundle_root: &Path) -> [PathBuf; 2] {
271 [
272 bundle_root.join("seeds.yaml"),
273 bundle_root.join("state").join("seeds.yaml"),
274 ]
275}
276
277fn placeholder_entry(uri: String) -> SeedEntry {
278 SeedEntry {
279 uri: uri.clone(),
280 format: SecretFormat::Text,
281 value: SeedValue::Text {
282 text: format!("placeholder for {uri}"),
283 },
284 description: Some("auto-applied placeholder".to_string()),
285 }
286}
287
288pub fn load_secret_keys_from_pack(pack_path: &Path) -> Result<Vec<String>> {
293 Ok(load_secret_requirements_from_pack(pack_path)?
294 .into_iter()
295 .map(|req| req.key)
296 .collect())
297}
298
299pub fn load_secret_requirements_from_pack(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
301 let file = std::fs::File::open(pack_path)?;
302 let mut archive = zip::ZipArchive::new(file)?;
303
304 for entry_name in &[
305 "assets/secret-requirements.json",
306 "assets/secret_requirements.json",
307 "secret-requirements.json",
308 "secret_requirements.json",
309 ] {
310 match archive.by_name(entry_name) {
311 Ok(reader) => {
312 let reqs: Vec<PackSecretRequirement> = serde_json::from_reader(reader)?;
313 return Ok(dedup_requirements(reqs));
314 }
315 Err(zip::result::ZipError::FileNotFound) => continue,
316 Err(err) => return Err(err.into()),
317 }
318 }
319
320 let mut reqs = Vec::new();
321 for index in 0..archive.len() {
322 let name = {
323 let entry = archive.by_index(index)?;
324 entry.name().to_string()
325 };
326 if name != "manifest.cbor" && !name.ends_with(".manifest.cbor") {
327 continue;
328 }
329 let mut entry = archive.by_name(&name)?;
330 let mut bytes = Vec::new();
331 std::io::Read::read_to_end(&mut entry, &mut bytes)?;
332 let value: CborValue = serde_cbor::from_slice(&bytes)?;
333 collect_secret_requirements_from_cbor(&value, &mut reqs);
334 }
335
336 Ok(dedup_requirements(reqs))
337}
338
339#[derive(Clone, Debug, serde::Deserialize)]
340pub struct PackSecretRequirement {
341 pub key: String,
342 #[serde(default = "default_required")]
343 pub required: bool,
344 #[serde(default)]
345 pub description: Option<String>,
346}
347
348fn default_required() -> bool {
349 true
350}
351
352fn dedup_requirements(reqs: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
353 let mut by_key = BTreeMap::new();
354 for req in reqs {
355 by_key.entry(req.key.clone()).or_insert(req);
356 }
357 by_key.into_values().collect()
358}
359
360fn collect_secret_requirements_from_cbor(value: &CborValue, out: &mut Vec<PackSecretRequirement>) {
361 match value {
362 CborValue::Array(values) => {
363 for value in values {
364 collect_secret_requirements_from_cbor(value, out);
365 }
366 }
367 CborValue::Map(map) => {
368 if let Some(req) = parse_secret_requirement_map(map) {
369 out.push(req);
370 }
371 for value in map.values() {
372 collect_secret_requirements_from_cbor(value, out);
373 }
374 }
375 _ => {}
376 }
377}
378
379fn parse_secret_requirement_map(
380 map: &BTreeMap<CborValue, CborValue>,
381) -> Option<PackSecretRequirement> {
382 let key = map_get_text(map, "key")?;
383 let has_secret_shape = map.contains_key(&CborValue::Text("required".to_string()))
384 || map.contains_key(&CborValue::Text("scope".to_string()))
385 || map.contains_key(&CborValue::Text("format".to_string()))
386 || map.contains_key(&CborValue::Text("description".to_string()));
387 if !has_secret_shape {
388 return None;
389 }
390 Some(PackSecretRequirement {
391 key,
392 required: map_get_bool(map, "required").unwrap_or(true),
393 description: map_get_text(map, "description"),
394 })
395}
396
397fn map_get_text(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<String> {
398 map.get(&CborValue::Text(key.to_string()))
399 .and_then(|value| match value {
400 CborValue::Text(text) => Some(text.clone()),
401 _ => None,
402 })
403}
404
405fn map_get_bool(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<bool> {
406 map.get(&CborValue::Text(key.to_string()))
407 .and_then(|value| match value {
408 CborValue::Bool(flag) => Some(*flag),
409 _ => None,
410 })
411}
412
413pub fn open_dev_store_for_env(bundle_root: &Path, env: &str) -> Result<DevStore> {
418 let store_path = ensure_path_for_env(bundle_root, env)?;
419 DevStore::with_path(&store_path).map_err(|err| {
420 anyhow!(
421 "failed to open dev secrets store {}: {err}",
422 store_path.display()
423 )
424 })
425}
426
427pub fn open_dev_store(bundle_root: &Path) -> Result<DevStore> {
431 let store_path = ensure_path(bundle_root)?;
432 DevStore::with_path(&store_path).map_err(|err| {
433 anyhow!(
434 "failed to open dev secrets store {}: {err}",
435 store_path.display()
436 )
437 })
438}
439
440#[cfg(test)]
453pub(crate) mod test_support {
454 use std::path::Path;
455 use std::sync::{Mutex, MutexGuard, OnceLock};
456
457 pub(crate) fn env_lock() -> MutexGuard<'static, ()> {
465 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
466 LOCK.get_or_init(|| Mutex::new(()))
467 .lock()
468 .unwrap_or_else(|poisoned| poisoned.into_inner())
469 }
470
471 pub(crate) struct StoreOverride {
474 _guard: MutexGuard<'static, ()>,
475 previous: Option<String>,
476 }
477
478 impl StoreOverride {
479 pub(crate) fn at(path: &Path) -> Self {
480 let guard = env_lock();
481 let previous = std::env::var(super::OVERRIDE_ENV).ok();
482 unsafe { std::env::set_var(super::OVERRIDE_ENV, path) };
486 Self {
487 _guard: guard,
488 previous,
489 }
490 }
491
492 pub(crate) fn in_dir(dir: &Path) -> Self {
494 Self::at(&dir.join(".dev.secrets.env"))
495 }
496 }
497
498 pub(crate) struct EnvLock(#[allow(dead_code)] MutexGuard<'static, ()>);
506
507 pub(crate) fn lock_env() -> EnvLock {
508 EnvLock(env_lock())
509 }
510
511 impl Drop for StoreOverride {
512 fn drop(&mut self) {
513 match self.previous.take() {
515 Some(value) => unsafe { std::env::set_var(super::OVERRIDE_ENV, value) },
516 None => unsafe { std::env::remove_var(super::OVERRIDE_ENV) },
517 }
518 }
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use std::io::Write;
526 use zip::write::SimpleFileOptions;
527
528 fn write_pack_with_secret_requirements(path: &Path, req_json: &str) -> anyhow::Result<()> {
529 let file = std::fs::File::create(path)?;
530 let mut zip = zip::ZipWriter::new(file);
531 zip.start_file(
532 "assets/secret-requirements.json",
533 SimpleFileOptions::default(),
534 )?;
535 zip.write_all(req_json.as_bytes())?;
536 zip.finish()?;
537 Ok(())
538 }
539
540 #[test]
547 fn env_store_path_has_expected_shape() {
548 if let Some(path) = env_store_dev_secrets_path("some-env") {
552 assert!(
553 path.ends_with("environments/some-env/.greentic/dev/.dev.secrets.env"),
554 "unexpected env-store path: {}",
555 path.display()
556 );
557 }
558 }
559
560 fn write_bundle_local_store(bundle: &Path) -> PathBuf {
562 let path = bundle.join(STORE_RELATIVE);
563 std::fs::create_dir_all(path.parent().expect("store parent")).expect("store dir");
564 std::fs::write(&path, "KEY=value\n").expect("write store");
565 path
566 }
567
568 #[test]
569 fn write_path_for_env_and_default_path_target_the_dev_store() {
570 let _env_lock = crate::secrets::test_support::lock_env();
571 let temp = tempfile::tempdir().expect("tempdir");
576 let bundle = temp.path().join("bundle");
577
578 let write = write_path_for_env(&bundle, "some-env");
579 assert!(
580 write.ends_with(STORE_RELATIVE),
581 "unexpected write path: {}",
582 write.display()
583 );
584
585 let default = default_path(&bundle);
586 assert!(
587 default.ends_with(STORE_RELATIVE),
588 "unexpected default path: {}",
589 default.display()
590 );
591 }
592
593 #[test]
599 fn write_path_for_env_targets_the_env_store_not_bundle_local() {
600 let temp = tempfile::tempdir().expect("tempdir");
601 let bundle = temp.path().join("bundle");
602 let Some(env_store) = env_store_dev_secrets_path("guard-env") else {
603 return; };
605 assert_eq!(
606 write_path_for_env(&bundle, "guard-env"),
607 env_store,
608 "env-explicit write must target the env store",
609 );
610 assert!(
611 !write_path_for_env(&bundle, "guard-env").starts_with(&bundle),
612 "env-explicit write must never land in the bundle-local store",
613 );
614 }
615
616 #[test]
617 fn find_existing_locates_a_bundle_local_dev_store() {
618 let _env_lock = test_support::lock_env();
621 let temp = tempfile::tempdir().expect("tempdir");
626 let bundle = temp.path().join("bundle");
627 let store = bundle.join(STORE_RELATIVE);
628 std::fs::create_dir_all(store.parent().expect("store parent")).expect("dev dir");
629 std::fs::write(&store, "KEY=value\n").expect("write store");
630
631 let found = find_existing(&bundle).expect("find dev store");
632 assert!(
633 found.exists(),
634 "found path should exist: {}",
635 found.display()
636 );
637 assert!(
638 found.ends_with(STORE_RELATIVE),
639 "unexpected found path: {}",
640 found.display()
641 );
642 }
643
644 #[test]
645 fn find_existing_with_override_prefers_override() {
646 let _env_lock = test_support::lock_env();
649 let temp = tempfile::tempdir().expect("tempdir");
650 let bundle = temp.path().join("bundle");
651 std::fs::create_dir_all(&bundle).expect("bundle dir");
652 let override_file = temp.path().join("custom.env");
653 std::fs::write(&override_file, "KEY=value\n").expect("write override");
654
655 let found = find_existing_with_override(&bundle, Some(&override_file));
656 assert_eq!(found.as_deref(), Some(override_file.as_path()));
657 }
658
659 #[test]
660 fn find_existing_with_override_falls_back_when_override_is_absent() {
661 let _env_lock = test_support::lock_env();
664 let temp = tempfile::tempdir().expect("tempdir");
665 let bundle = temp.path().join("bundle");
666 std::fs::create_dir_all(&bundle).expect("bundle dir");
667 write_bundle_local_store(&bundle);
668 let missing_override = temp.path().join("does-not-exist.env");
669
670 for override_arg in [None, Some(missing_override.as_path())] {
675 let found = find_existing_with_override(&bundle, override_arg)
676 .expect("existing store must be discovered");
677 assert!(found.exists(), "resolved store does not exist: {found:?}");
678 assert!(
679 found.ends_with(".dev.secrets.env"),
680 "unexpected store file: {found:?}"
681 );
682 }
683 }
684
685 #[test]
686 fn find_existing_discovers_the_bundle_local_store() {
687 let _env_lock = test_support::lock_env();
690 let temp = tempfile::tempdir().expect("tempdir");
691 let bundle = temp.path().join("bundle");
692 std::fs::create_dir_all(&bundle).expect("bundle dir");
693 write_bundle_local_store(&bundle);
694
695 let found = find_existing(&bundle).expect("existing store must be discovered");
696 assert!(found.exists(), "resolved store does not exist: {found:?}");
697 }
698
699 #[test]
700 fn read_candidate_paths_ends_with_bundle_local_candidates() {
701 let _env_lock = test_support::lock_env();
704 let temp = tempfile::tempdir().expect("tempdir");
705 let bundle = temp.path().join("bundle");
706
707 let candidates = read_candidate_paths(&bundle);
708 assert!(
712 candidates.len() == 2 || candidates.len() == 3,
713 "unexpected candidates: {candidates:?}"
714 );
715 assert_eq!(
716 &candidates[candidates.len() - 2..],
717 &[
718 bundle.join(STORE_RELATIVE),
719 bundle.join(STORE_STATE_RELATIVE)
720 ]
721 );
722 }
723
724 #[test]
725 fn write_path_for_env_always_targets_a_dev_store_file() {
726 let _env_lock = test_support::lock_env();
729 let temp = tempfile::tempdir().expect("tempdir");
730 let bundle = temp.path().join("bundle");
731
732 let path = write_path_for_env(&bundle, "some-env");
735 assert!(
736 path.ends_with(STORE_RELATIVE),
737 "unexpected write path: {}",
738 path.display()
739 );
740 }
741
742 #[test]
743 fn default_path_creates_nothing() {
744 let _env_lock = test_support::lock_env();
747 let temp = tempfile::tempdir().expect("tempdir");
748 let bundle = temp.path().join("bundle");
749
750 let path = default_path(&bundle);
751 assert!(
752 path.ends_with(".dev.secrets.env"),
753 "unexpected default path: {}",
754 path.display()
755 );
756 assert!(
757 !bundle.exists(),
758 "default_path must not touch the filesystem"
759 );
760 }
761
762 #[test]
763 fn load_secret_keys_from_pack_reads_requirements() {
764 let temp = tempfile::tempdir().expect("tempdir");
765 let pack = temp.path().join("provider.gtpack");
766 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"},{"key":"API_SECRET"}]"#)
767 .expect("write pack");
768
769 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
770 assert_eq!(
771 keys,
772 vec!["API_SECRET".to_string(), "BOT_TOKEN".to_string()]
773 );
774 }
775
776 #[test]
777 fn load_secret_keys_from_pack_reads_cbor_manifest_requirements() {
778 let temp = tempfile::tempdir().expect("tempdir");
779 let pack = temp.path().join("provider.gtpack");
780 let file = std::fs::File::create(&pack).expect("create pack");
781 let mut zip = zip::ZipWriter::new(file);
782 zip.start_file("manifest.cbor", SimpleFileOptions::default())
783 .expect("start entry");
784 let manifest = serde_json::json!({
785 "components": [
786 {
787 "host": {
788 "secrets": {
789 "required": [
790 {
791 "key": "auth.param.get_weather.key",
792 "required": true,
793 "description": "Weather key",
794 "scope": {"env": "runtime", "tenant": "runtime"},
795 "format": "text"
796 }
797 ]
798 }
799 }
800 }
801 ]
802 });
803 let bytes = serde_cbor::to_vec(&manifest).expect("serialize cbor");
804 zip.write_all(&bytes).expect("write manifest");
805 zip.finish().expect("finish zip");
806
807 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
808 assert_eq!(keys, vec!["auth.param.get_weather.key".to_string()]);
809
810 let reqs = load_secret_requirements_from_pack(&pack).expect("load reqs");
811 assert_eq!(reqs.len(), 1);
812 assert_eq!(reqs[0].description.as_deref(), Some("Weather key"));
813 }
814
815 #[test]
816 fn load_secret_keys_from_pack_returns_empty_without_requirements() {
817 let temp = tempfile::tempdir().expect("tempdir");
818 let pack = temp.path().join("provider.gtpack");
819 let file = std::fs::File::create(&pack).expect("create pack");
820 let mut zip = zip::ZipWriter::new(file);
821 zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
822 .expect("start entry");
823 zip.write_all(b"questions: []\n").expect("write setup");
824 zip.finish().expect("finish zip");
825
826 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
827 assert!(keys.is_empty());
828 }
829
830 #[test]
831 fn cbor_manifest_scan_skips_maps_that_are_not_secret_requirements() {
832 let temp = tempfile::tempdir().expect("tempdir");
833 let pack = temp.path().join("provider.gtpack");
834 let file = std::fs::File::create(&pack).expect("create pack");
835 let mut zip = zip::ZipWriter::new(file);
836 zip.start_file("manifest.cbor", SimpleFileOptions::default())
837 .expect("start entry");
838 let manifest = serde_json::json!({
839 "routes": [{"key": "not-a-secret", "target": "node-a"}],
842 "indexed": [{"key": 7, "required": true}],
844 "secrets": [{"key": "loose.secret", "required": "yes"}],
846 });
847 let bytes = serde_cbor::to_vec(&manifest).expect("serialize cbor");
848 zip.write_all(&bytes).expect("write manifest");
849 zip.finish().expect("finish zip");
850
851 let reqs = load_secret_requirements_from_pack(&pack).expect("load reqs");
852 assert_eq!(reqs.len(), 1, "unexpected requirements: {reqs:?}");
853 assert_eq!(reqs[0].key, "loose.secret");
854 assert!(reqs[0].required, "non-bool `required` must default to true");
855 assert!(reqs[0].description.is_none());
856 }
857
858 #[tokio::test]
859 async fn ensure_pack_secrets_is_a_no_op_without_requirements() {
860 let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
861 let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
862 let temp = tempfile::tempdir().expect("tempdir");
863 let bundle = temp.path().join("bundle");
864 std::fs::create_dir_all(&bundle).expect("bundle dir");
865 let pack = temp.path().join("provider.gtpack");
866 write_pack_with_secret_requirements(&pack, "[]").expect("pack");
867
868 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
869 setup
870 .ensure_pack_secrets(&pack, "messaging-telegram")
871 .await
872 .expect("ensure secrets");
873
874 assert!(
875 setup
876 .store_path()
877 .parent()
878 .is_some_and(|parent| parent.is_dir()),
879 "store parent must be created: {}",
880 setup.store_path().display()
881 );
882 assert!(
883 setup.store_path().ends_with(".dev.secrets.env"),
884 "unexpected store path: {}",
885 setup.store_path().display()
886 );
887 }
888
889 #[tokio::test]
890 async fn ensure_pack_secrets_is_a_noop_without_requirements() {
891 let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
892 let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
893 let temp = tempfile::tempdir().expect("tempdir");
894 let bundle = temp.path().join("bundle");
895 std::fs::create_dir_all(&bundle).expect("bundle dir");
896
897 let pack = temp.path().join("provider.gtpack");
899 let file = std::fs::File::create(&pack).expect("create pack");
900 let mut zip = zip::ZipWriter::new(file);
901 zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
902 .expect("start entry");
903 zip.write_all(b"questions: []\n").expect("write setup");
904 zip.finish().expect("finish zip");
905
906 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", None).expect("setup");
907 assert!(setup.store_path().ends_with(".dev.secrets.env"));
909
910 setup
911 .ensure_pack_secrets(&pack, "messaging-telegram")
912 .await
913 .expect("noop for empty requirements");
914 }
915
916 #[tokio::test]
917 async fn ensure_pack_secrets_seeds_placeholders_for_missing_keys() {
918 let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
919 let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
920 let temp = tempfile::tempdir().expect("tempdir");
921 let bundle = temp.path().join("bundle");
922 std::fs::create_dir_all(&bundle).expect("bundle dir");
923 let pack = temp.path().join("provider.gtpack");
924 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
925
926 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
927 setup
928 .ensure_pack_secrets(&pack, "messaging-telegram")
929 .await
930 .expect("ensure secrets");
931
932 let uri = canonical_secret_uri(
933 "dev",
934 "tenant-a",
935 Some("core"),
936 "messaging-telegram",
937 "BOT_TOKEN",
938 );
939 let value = setup.store().get(&uri).await.expect("seeded value");
940 let value = String::from_utf8(value).expect("utf8");
941 assert!(
942 value.contains("placeholder for secrets://"),
943 "unexpected placeholder value: {value}"
944 );
945 }
946
947 #[tokio::test]
948 async fn ensure_pack_secrets_uses_seed_values_when_available() {
949 let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
950 let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
951 let temp = tempfile::tempdir().expect("tempdir");
952 let bundle = temp.path().join("bundle");
953 std::fs::create_dir_all(&bundle).expect("bundle dir");
954 let seed_uri = canonical_secret_uri(
955 "dev",
956 "tenant-a",
957 Some("core"),
958 "messaging-telegram",
959 "BOT_TOKEN",
960 );
961 let seeds_yaml = serde_yaml_bw::to_string(&SeedDoc {
962 entries: vec![SeedEntry {
963 uri: seed_uri.clone(),
964 format: SecretFormat::Text,
965 value: SeedValue::Text {
966 text: "seeded-secret".to_string(),
967 },
968 description: Some("test seed".to_string()),
969 }],
970 })
971 .expect("serialize seeds");
972 std::fs::write(bundle.join("seeds.yaml"), seeds_yaml).expect("write seeds");
973
974 let pack = temp.path().join("provider.gtpack");
975 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
976
977 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
978 setup
979 .ensure_pack_secrets(&pack, "messaging-telegram")
980 .await
981 .expect("ensure secrets");
982
983 let value = setup.store().get(&seed_uri).await.expect("seeded value");
984 let value = String::from_utf8(value).expect("utf8");
985 assert_eq!(value, "seeded-secret");
986 }
987}