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 find_existing(bundle_root: &Path) -> Option<PathBuf> {
33 find_existing_with_override(bundle_root, override_path().as_deref())
34}
35
36pub fn find_existing_with_override(
38 bundle_root: &Path,
39 override_path: Option<&Path>,
40) -> Option<PathBuf> {
41 if let Some(path) = override_path
42 && path.exists()
43 {
44 return Some(path.to_path_buf());
45 }
46 candidate_paths(bundle_root)
47 .into_iter()
48 .find(|candidate| candidate.exists())
49}
50
51pub fn ensure_path(bundle_root: &Path) -> Result<PathBuf> {
53 if let Some(path) = override_path() {
54 ensure_parent(&path)?;
55 return Ok(path);
56 }
57 let path = bundle_root.join(STORE_RELATIVE);
58 ensure_parent(&path)?;
59 Ok(path)
60}
61
62pub fn default_path(bundle_root: &Path) -> PathBuf {
64 bundle_root.join(STORE_RELATIVE)
65}
66
67fn candidate_paths(bundle_root: &Path) -> [PathBuf; 2] {
68 [
69 bundle_root.join(STORE_RELATIVE),
70 bundle_root.join(STORE_STATE_RELATIVE),
71 ]
72}
73
74fn ensure_parent(path: &Path) -> Result<()> {
75 if let Some(parent) = path.parent() {
76 std::fs::create_dir_all(parent)?;
77 }
78 Ok(())
79}
80
81pub struct SecretsSetup {
88 store: DevStore,
89 store_path: PathBuf,
90 env: String,
91 tenant: String,
92 team: Option<String>,
93 seeds: HashMap<String, SeedEntry>,
94}
95
96impl SecretsSetup {
97 pub fn new(bundle_root: &Path, env: &str, tenant: &str, team: Option<&str>) -> Result<Self> {
98 let store_path = ensure_path(bundle_root)?;
99 info!(path = %store_path.display(), "secrets: using dev store backend");
100 let store = DevStore::with_path(&store_path).map_err(|err| {
101 anyhow!(
102 "failed to open dev secrets store {}: {err}",
103 store_path.display()
104 )
105 })?;
106 let seeds = load_seed_entries(bundle_root)?;
107 Ok(Self {
108 store,
109 store_path,
110 env: env.to_string(),
111 tenant: tenant.to_string(),
112 team: team.map(|v| v.to_string()),
113 seeds,
114 })
115 }
116
117 pub fn store_path(&self) -> &Path {
119 &self.store_path
120 }
121
122 pub fn store(&self) -> &DevStore {
124 &self.store
125 }
126
127 pub async fn ensure_pack_secrets(&self, pack_path: &Path, provider_id: &str) -> Result<()> {
132 let keys = load_secret_keys_from_pack(pack_path)?;
133 if keys.is_empty() {
134 return Ok(());
135 }
136
137 let mut missing = Vec::new();
138 for key in keys {
139 let uri = canonical_secret_uri(
140 &self.env,
141 &self.tenant,
142 self.team.as_deref(),
143 provider_id,
144 &key,
145 );
146 debug!(uri = %uri, provider = %provider_id, key = %key, "canonicalized secret requirement");
147 match self.store.get(&uri).await {
148 Ok(_) => continue,
149 Err(SecretError::NotFound { .. }) => {
150 let source = if self.seeds.contains_key(&uri) {
151 "seeds.yaml"
152 } else {
153 "placeholder"
154 };
155 debug!(uri = %uri, source, "seeding missing secret");
156 missing.push(
157 self.seeds
158 .get(&uri)
159 .cloned()
160 .unwrap_or_else(|| placeholder_entry(uri)),
161 );
162 }
163 Err(err) => {
164 return Err(anyhow!("failed to read secret {uri}: {err}"));
165 }
166 }
167 }
168
169 if missing.is_empty() {
170 return Ok(());
171 }
172 let report = apply_seed(
173 &self.store,
174 &SeedDoc { entries: missing },
175 ApplyOptions::default(),
176 )
177 .await;
178 if !report.failed.is_empty() {
179 return Err(anyhow!("failed to seed secrets: {:?}", report.failed));
180 }
181 Ok(())
182 }
183}
184
185fn load_seed_entries(bundle_root: &Path) -> Result<HashMap<String, SeedEntry>> {
188 for candidate in seed_paths(bundle_root) {
189 if candidate.exists() {
190 let contents = std::fs::read_to_string(&candidate)?;
191 let doc: SeedDoc = serde_yaml_bw::from_str(&contents)?;
192 return Ok(doc
193 .entries
194 .into_iter()
195 .map(|entry| (entry.uri.clone(), entry))
196 .collect());
197 }
198 }
199 Ok(HashMap::new())
200}
201
202fn seed_paths(bundle_root: &Path) -> [PathBuf; 2] {
203 [
204 bundle_root.join("seeds.yaml"),
205 bundle_root.join("state").join("seeds.yaml"),
206 ]
207}
208
209fn placeholder_entry(uri: String) -> SeedEntry {
210 SeedEntry {
211 uri: uri.clone(),
212 format: SecretFormat::Text,
213 value: SeedValue::Text {
214 text: format!("placeholder for {uri}"),
215 },
216 description: Some("auto-applied placeholder".to_string()),
217 }
218}
219
220pub fn load_secret_keys_from_pack(pack_path: &Path) -> Result<Vec<String>> {
225 Ok(load_secret_requirements_from_pack(pack_path)?
226 .into_iter()
227 .map(|req| req.key)
228 .collect())
229}
230
231pub fn load_secret_requirements_from_pack(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
233 let file = std::fs::File::open(pack_path)?;
234 let mut archive = zip::ZipArchive::new(file)?;
235
236 for entry_name in &[
237 "assets/secret-requirements.json",
238 "assets/secret_requirements.json",
239 "secret-requirements.json",
240 "secret_requirements.json",
241 ] {
242 match archive.by_name(entry_name) {
243 Ok(reader) => {
244 let reqs: Vec<PackSecretRequirement> = serde_json::from_reader(reader)?;
245 return Ok(dedup_requirements(reqs));
246 }
247 Err(zip::result::ZipError::FileNotFound) => continue,
248 Err(err) => return Err(err.into()),
249 }
250 }
251
252 let mut reqs = Vec::new();
253 for index in 0..archive.len() {
254 let name = {
255 let entry = archive.by_index(index)?;
256 entry.name().to_string()
257 };
258 if name != "manifest.cbor" && !name.ends_with(".manifest.cbor") {
259 continue;
260 }
261 let mut entry = archive.by_name(&name)?;
262 let mut bytes = Vec::new();
263 std::io::Read::read_to_end(&mut entry, &mut bytes)?;
264 let value: CborValue = serde_cbor::from_slice(&bytes)?;
265 collect_secret_requirements_from_cbor(&value, &mut reqs);
266 }
267
268 Ok(dedup_requirements(reqs))
269}
270
271#[derive(Clone, Debug, serde::Deserialize)]
272pub struct PackSecretRequirement {
273 pub key: String,
274 #[serde(default = "default_required")]
275 pub required: bool,
276 #[serde(default)]
277 pub description: Option<String>,
278}
279
280fn default_required() -> bool {
281 true
282}
283
284#[derive(Clone, Debug, PartialEq, Eq)]
290pub struct GeneratedSecret {
291 pub key: String,
292 pub required: bool,
293 pub policy: String,
295 pub length: usize,
297 pub encoding: String,
299 pub regenerate_if_present: bool,
301 pub scope_team: Option<String>,
303}
304
305pub fn load_generated_secrets_from_pack(pack_path: &Path) -> Result<Vec<GeneratedSecret>> {
308 let file = std::fs::File::open(pack_path)?;
309 let mut archive = zip::ZipArchive::new(file)?;
310 let mut out = Vec::new();
311 for index in 0..archive.len() {
312 let name = {
313 let entry = archive.by_index(index)?;
314 entry.name().to_string()
315 };
316 if name != "manifest.cbor" && !name.ends_with(".manifest.cbor") {
317 continue;
318 }
319 let mut entry = archive.by_name(&name)?;
320 let mut bytes = Vec::new();
321 std::io::Read::read_to_end(&mut entry, &mut bytes)?;
322 let value: CborValue = serde_cbor::from_slice(&bytes)?;
323 collect_generated_secrets_from_cbor(&value, &mut out);
324 }
325 Ok(out)
326}
327
328fn collect_generated_secrets_from_cbor(value: &CborValue, out: &mut Vec<GeneratedSecret>) {
329 match value {
330 CborValue::Array(values) => {
331 for value in values {
332 collect_generated_secrets_from_cbor(value, out);
333 }
334 }
335 CborValue::Map(map) => {
336 if let Some(CborValue::Text(kind)) = map.get(&CborValue::Text("kind".to_string()))
337 && kind == "greentic.generated-secrets.v1"
338 && let Some(CborValue::Map(inline)) =
339 map.get(&CborValue::Text("inline".to_string()))
340 && let Some(CborValue::Array(secrets)) =
341 inline.get(&CborValue::Text("secrets".to_string()))
342 {
343 for secret in secrets {
344 if let CborValue::Map(secret_map) = secret
345 && let Some(generated) = parse_generated_secret_map(secret_map)
346 {
347 out.push(generated);
348 }
349 }
350 }
351 for value in map.values() {
352 collect_generated_secrets_from_cbor(value, out);
353 }
354 }
355 _ => {}
356 }
357}
358
359fn parse_generated_secret_map(map: &BTreeMap<CborValue, CborValue>) -> Option<GeneratedSecret> {
360 let get = |key: &str| map.get(&CborValue::Text(key.to_string()));
361 let key = match get("key") {
362 Some(CborValue::Text(value)) => value.clone(),
363 _ => return None,
364 };
365 let required = match get("required") {
366 Some(CborValue::Bool(value)) => *value,
367 _ => true,
368 };
369 let policy = match get("policy") {
370 Some(CborValue::Text(value)) => value.clone(),
371 _ => "random".to_string(),
372 };
373 let length = match get("length") {
374 Some(CborValue::Integer(value)) if *value > 0 => *value as usize,
375 _ => 32,
376 };
377 let encoding = match get("encoding") {
378 Some(CborValue::Text(value)) => value.clone(),
379 _ => "raw_text".to_string(),
380 };
381 let regenerate_if_present = matches!(get("regenerate_if_present"), Some(CborValue::Bool(true)));
382 let scope_team = match get("scope") {
383 Some(CborValue::Map(scope)) => match scope.get(&CborValue::Text("team".to_string())) {
384 Some(CborValue::Text(team)) => Some(team.clone()),
385 _ => None,
386 },
387 _ => None,
388 };
389 Some(GeneratedSecret {
390 key,
391 required,
392 policy,
393 length,
394 encoding,
395 regenerate_if_present,
396 scope_team,
397 })
398}
399
400pub fn generate_secret_value(_policy: &str, length: usize, encoding: &str) -> String {
404 use base64::Engine as _;
405 use rand::Rng as _;
406
407 let count = if length == 0 { 32 } else { length };
408 let mut bytes = vec![0u8; count];
409 rand::rng().fill_bytes(&mut bytes);
410 match encoding {
411 "base64url" => base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes),
412 "hex" => bytes.iter().map(|byte| format!("{byte:02x}")).collect(),
413 _ => {
416 const CHARSET: &[u8] =
417 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
418 bytes
419 .iter()
420 .map(|byte| CHARSET[(*byte as usize) % CHARSET.len()] as char)
421 .collect()
422 }
423 }
424}
425
426fn dedup_requirements(reqs: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
427 let mut by_key = BTreeMap::new();
428 for req in reqs {
429 by_key.entry(req.key.clone()).or_insert(req);
430 }
431 by_key.into_values().collect()
432}
433
434fn collect_secret_requirements_from_cbor(value: &CborValue, out: &mut Vec<PackSecretRequirement>) {
435 match value {
436 CborValue::Array(values) => {
437 for value in values {
438 collect_secret_requirements_from_cbor(value, out);
439 }
440 }
441 CborValue::Map(map) => {
442 if let Some(req) = parse_secret_requirement_map(map) {
443 out.push(req);
444 }
445 for value in map.values() {
446 collect_secret_requirements_from_cbor(value, out);
447 }
448 }
449 _ => {}
450 }
451}
452
453fn parse_secret_requirement_map(
454 map: &BTreeMap<CborValue, CborValue>,
455) -> Option<PackSecretRequirement> {
456 let key = map_get_text(map, "key")?;
457 let has_secret_shape = map.contains_key(&CborValue::Text("required".to_string()))
458 || map.contains_key(&CborValue::Text("scope".to_string()))
459 || map.contains_key(&CborValue::Text("format".to_string()))
460 || map.contains_key(&CborValue::Text("description".to_string()));
461 if !has_secret_shape {
462 return None;
463 }
464 Some(PackSecretRequirement {
465 key,
466 required: map_get_bool(map, "required").unwrap_or(true),
467 description: map_get_text(map, "description"),
468 })
469}
470
471fn map_get_text(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<String> {
472 map.get(&CborValue::Text(key.to_string()))
473 .and_then(|value| match value {
474 CborValue::Text(text) => Some(text.clone()),
475 _ => None,
476 })
477}
478
479fn map_get_bool(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<bool> {
480 map.get(&CborValue::Text(key.to_string()))
481 .and_then(|value| match value {
482 CborValue::Bool(flag) => Some(*flag),
483 _ => None,
484 })
485}
486
487pub fn open_dev_store(bundle_root: &Path) -> Result<DevStore> {
489 let store_path = ensure_path(bundle_root)?;
490 DevStore::with_path(&store_path).map_err(|err| {
491 anyhow!(
492 "failed to open dev secrets store {}: {err}",
493 store_path.display()
494 )
495 })
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use std::io::Write;
502 use zip::write::SimpleFileOptions;
503
504 fn write_pack_with_secret_requirements(path: &Path, req_json: &str) -> anyhow::Result<()> {
505 let file = std::fs::File::create(path)?;
506 let mut zip = zip::ZipWriter::new(file);
507 zip.start_file(
508 "assets/secret-requirements.json",
509 SimpleFileOptions::default(),
510 )?;
511 zip.write_all(req_json.as_bytes())?;
512 zip.finish()?;
513 Ok(())
514 }
515
516 #[test]
517 fn ensure_path_creates_parent_directories() {
518 let temp = tempfile::tempdir().expect("tempdir");
519 let bundle = temp.path().join("bundle");
520 std::fs::create_dir_all(&bundle).expect("bundle dir");
521 let path = ensure_path(&bundle).expect("ensure path");
522 assert!(path.ends_with(".greentic/dev/.dev.secrets.env"));
523 assert!(path.parent().expect("parent").exists());
524 }
525
526 #[test]
527 fn loads_generated_secrets_from_manifest_cbor() {
528 use serde_cbor::Value as C;
529 let entry = |k: &str, v: C| (C::Text(k.to_string()), v);
530 let secret = C::Map(BTreeMap::from([
531 entry("key", C::Text("jwt_signing_key".into())),
532 entry("required", C::Bool(true)),
533 entry("policy", C::Text("random".into())),
534 entry("length", C::Integer(20)),
535 entry("encoding", C::Text("raw_text".into())),
536 entry("regenerate_if_present", C::Bool(false)),
537 entry(
538 "scope",
539 C::Map(BTreeMap::from([entry("team", C::Text("_".into()))])),
540 ),
541 ]));
542 let ext = C::Map(BTreeMap::from([
543 entry("kind", C::Text("greentic.generated-secrets.v1".into())),
544 entry(
545 "inline",
546 C::Map(BTreeMap::from([entry("secrets", C::Array(vec![secret]))])),
547 ),
548 ]));
549 let manifest = C::Map(BTreeMap::from([entry("extensions", C::Array(vec![ext]))]));
550 let bytes = serde_cbor::to_vec(&manifest).expect("encode manifest");
551
552 let temp = tempfile::tempdir().expect("tempdir");
553 let pack = temp.path().join("p.gtpack");
554 let file = std::fs::File::create(&pack).expect("create pack");
555 let mut zip = zip::ZipWriter::new(file);
556 zip.start_file("manifest.cbor", SimpleFileOptions::default())
557 .expect("start file");
558 zip.write_all(&bytes).expect("write");
559 zip.finish().expect("finish");
560
561 let secrets = load_generated_secrets_from_pack(&pack).expect("load");
562 assert_eq!(secrets.len(), 1);
563 let secret = &secrets[0];
564 assert_eq!(secret.key, "jwt_signing_key");
565 assert!(secret.required);
566 assert_eq!(secret.policy, "random");
567 assert_eq!(secret.length, 20);
568 assert_eq!(secret.encoding, "raw_text");
569 assert!(!secret.regenerate_if_present);
570 assert_eq!(secret.scope_team.as_deref(), Some("_"));
571 }
572
573 #[test]
574 fn generate_secret_value_respects_length_and_encoding() {
575 let raw = generate_secret_value("random", 20, "raw_text");
576 assert_eq!(raw.len(), 20);
577 assert!(raw.chars().all(|c| c.is_ascii_alphanumeric()));
578
579 let hex = generate_secret_value("random", 16, "hex");
580 assert_eq!(hex.len(), 32); assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
582
583 assert!(!generate_secret_value("random", 24, "base64url").is_empty());
584 assert_ne!(
586 generate_secret_value("random", 32, "raw_text"),
587 generate_secret_value("random", 32, "raw_text")
588 );
589 }
590
591 #[test]
592 fn find_existing_with_override_prefers_override() {
593 let temp = tempfile::tempdir().expect("tempdir");
594 let bundle = temp.path().join("bundle");
595 std::fs::create_dir_all(&bundle).expect("bundle dir");
596 let override_file = temp.path().join("custom.env");
597 std::fs::write(&override_file, "KEY=value\n").expect("write override");
598
599 let found = find_existing_with_override(&bundle, Some(&override_file));
600 assert_eq!(found.as_deref(), Some(override_file.as_path()));
601 }
602
603 #[test]
604 fn find_existing_finds_default_locations() {
605 let temp = tempfile::tempdir().expect("tempdir");
606 let bundle = temp.path().join("bundle");
607 let store_path = bundle.join(STORE_RELATIVE);
608 std::fs::create_dir_all(store_path.parent().expect("parent")).expect("create dirs");
609 std::fs::write(&store_path, "K=V\n").expect("write store");
610
611 let found = find_existing_with_override(&bundle, None).expect("found");
612 assert_eq!(found, store_path);
613 }
614
615 #[test]
616 fn load_secret_keys_from_pack_reads_requirements() {
617 let temp = tempfile::tempdir().expect("tempdir");
618 let pack = temp.path().join("provider.gtpack");
619 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"},{"key":"API_SECRET"}]"#)
620 .expect("write pack");
621
622 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
623 assert_eq!(
624 keys,
625 vec!["API_SECRET".to_string(), "BOT_TOKEN".to_string()]
626 );
627 }
628
629 #[test]
630 fn load_secret_keys_from_pack_reads_cbor_manifest_requirements() {
631 let temp = tempfile::tempdir().expect("tempdir");
632 let pack = temp.path().join("provider.gtpack");
633 let file = std::fs::File::create(&pack).expect("create pack");
634 let mut zip = zip::ZipWriter::new(file);
635 zip.start_file("manifest.cbor", SimpleFileOptions::default())
636 .expect("start entry");
637 let manifest = serde_json::json!({
638 "components": [
639 {
640 "host": {
641 "secrets": {
642 "required": [
643 {
644 "key": "auth.param.get_weather.key",
645 "required": true,
646 "description": "Weather key",
647 "scope": {"env": "runtime", "tenant": "runtime"},
648 "format": "text"
649 }
650 ]
651 }
652 }
653 }
654 ]
655 });
656 let bytes = serde_cbor::to_vec(&manifest).expect("serialize cbor");
657 zip.write_all(&bytes).expect("write manifest");
658 zip.finish().expect("finish zip");
659
660 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
661 assert_eq!(keys, vec!["auth.param.get_weather.key".to_string()]);
662
663 let reqs = load_secret_requirements_from_pack(&pack).expect("load reqs");
664 assert_eq!(reqs.len(), 1);
665 assert_eq!(reqs[0].description.as_deref(), Some("Weather key"));
666 }
667
668 #[test]
669 fn load_secret_keys_from_pack_returns_empty_without_requirements() {
670 let temp = tempfile::tempdir().expect("tempdir");
671 let pack = temp.path().join("provider.gtpack");
672 let file = std::fs::File::create(&pack).expect("create pack");
673 let mut zip = zip::ZipWriter::new(file);
674 zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
675 .expect("start entry");
676 zip.write_all(b"questions: []\n").expect("write setup");
677 zip.finish().expect("finish zip");
678
679 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
680 assert!(keys.is_empty());
681 }
682
683 #[tokio::test]
684 async fn ensure_pack_secrets_seeds_placeholders_for_missing_keys() {
685 let temp = tempfile::tempdir().expect("tempdir");
686 let bundle = temp.path().join("bundle");
687 std::fs::create_dir_all(&bundle).expect("bundle dir");
688 let pack = temp.path().join("provider.gtpack");
689 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
690
691 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
692 setup
693 .ensure_pack_secrets(&pack, "messaging-telegram")
694 .await
695 .expect("ensure secrets");
696
697 let uri = canonical_secret_uri(
698 "dev",
699 "tenant-a",
700 Some("core"),
701 "messaging-telegram",
702 "BOT_TOKEN",
703 );
704 let value = setup.store().get(&uri).await.expect("seeded value");
705 let value = String::from_utf8(value).expect("utf8");
706 assert!(
707 value.contains("placeholder for secrets://"),
708 "unexpected placeholder value: {value}"
709 );
710 }
711
712 #[tokio::test]
713 async fn ensure_pack_secrets_uses_seed_values_when_available() {
714 let temp = tempfile::tempdir().expect("tempdir");
715 let bundle = temp.path().join("bundle");
716 std::fs::create_dir_all(&bundle).expect("bundle dir");
717 let seed_uri = canonical_secret_uri(
718 "dev",
719 "tenant-a",
720 Some("core"),
721 "messaging-telegram",
722 "BOT_TOKEN",
723 );
724 let seeds_yaml = serde_yaml_bw::to_string(&SeedDoc {
725 entries: vec![SeedEntry {
726 uri: seed_uri.clone(),
727 format: SecretFormat::Text,
728 value: SeedValue::Text {
729 text: "seeded-secret".to_string(),
730 },
731 description: Some("test seed".to_string()),
732 }],
733 })
734 .expect("serialize seeds");
735 std::fs::write(bundle.join("seeds.yaml"), seeds_yaml).expect("write seeds");
736
737 let pack = temp.path().join("provider.gtpack");
738 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
739
740 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
741 setup
742 .ensure_pack_secrets(&pack, "messaging-telegram")
743 .await
744 .expect("ensure secrets");
745
746 let value = setup.store().get(&seed_uri).await.expect("seeded value");
747 let value = String::from_utf8(value).expect("utf8");
748 assert_eq!(value, "seeded-secret");
749 }
750}