use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use greentic_secrets_lib::core::Error as SecretError;
use greentic_secrets_lib::{
ApplyOptions, DevStore, SecretFormat, SecretsStore, SeedDoc, SeedEntry, SeedValue, apply_seed,
};
use serde_cbor::Value as CborValue;
use tracing::{debug, info};
use crate::canonical_secret_uri;
const STORE_RELATIVE: &str = ".greentic/dev/.dev.secrets.env";
const STORE_STATE_RELATIVE: &str = ".greentic/state/dev/.dev.secrets.env";
const OVERRIDE_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
pub fn override_path() -> Option<PathBuf> {
assert_store_access_is_guarded();
std::env::var(OVERRIDE_ENV).ok().map(PathBuf::from)
}
#[cfg(test)]
fn assert_store_access_is_guarded() {
assert!(
test_support::store_access_is_guarded(),
"dev secrets store resolved without a `secrets::test_support` guard — \
start this test with `let _store = crate::secrets::test_support::isolated_store();` \
(or `lock_env()` when it only reads path resolution)"
);
}
#[cfg(not(test))]
#[inline]
fn assert_store_access_is_guarded() {}
pub fn env_store_dev_secrets_path(env: &str) -> Option<PathBuf> {
greentic_deployer::environment::LocalFsStore::default_root()
.map(|root| root.join(env).join(STORE_RELATIVE))
}
fn selected_env() -> Option<String> {
std::env::var("GREENTIC_ENV")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|raw| crate::resolve_env(Some(&raw)))
}
fn write_path_for_env(bundle_root: &Path, env: &str) -> PathBuf {
env_store_dev_secrets_path(env).unwrap_or_else(|| bundle_root.join(STORE_RELATIVE))
}
fn bare_write_path(bundle_root: &Path) -> PathBuf {
selected_env()
.and_then(|env| env_store_dev_secrets_path(&env))
.unwrap_or_else(|| bundle_root.join(STORE_RELATIVE))
}
fn read_candidate_paths(bundle_root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(env_store) = selected_env().and_then(|env| env_store_dev_secrets_path(&env)) {
out.push(env_store);
}
out.push(bundle_root.join(STORE_RELATIVE));
out.push(bundle_root.join(STORE_STATE_RELATIVE));
out
}
pub fn find_existing(bundle_root: &Path) -> Option<PathBuf> {
find_existing_with_override(bundle_root, override_path().as_deref())
}
pub fn find_existing_with_override(
bundle_root: &Path,
override_path: Option<&Path>,
) -> Option<PathBuf> {
if let Some(path) = override_path
&& path.exists()
{
return Some(path.to_path_buf());
}
read_candidate_paths(bundle_root)
.into_iter()
.find(|candidate| candidate.exists())
}
pub fn ensure_path(bundle_root: &Path) -> Result<PathBuf> {
if let Some(path) = override_path() {
ensure_parent(&path)?;
return Ok(path);
}
let path = bare_write_path(bundle_root);
ensure_parent(&path)?;
Ok(path)
}
pub fn ensure_path_for_env(bundle_root: &Path, env: &str) -> Result<PathBuf> {
if let Some(path) = override_path() {
ensure_parent(&path)?;
return Ok(path);
}
let path = write_path_for_env(bundle_root, env);
ensure_parent(&path)?;
Ok(path)
}
pub fn default_path(bundle_root: &Path) -> PathBuf {
override_path().unwrap_or_else(|| bare_write_path(bundle_root))
}
fn ensure_parent(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(())
}
pub struct SecretsSetup {
store: DevStore,
store_path: PathBuf,
env: String,
tenant: String,
team: Option<String>,
seeds: HashMap<String, SeedEntry>,
}
impl SecretsSetup {
pub fn new(bundle_root: &Path, env: &str, tenant: &str, team: Option<&str>) -> Result<Self> {
let store_path = ensure_path_for_env(bundle_root, env)?;
info!(path = %store_path.display(), "secrets: using dev store backend");
let store = DevStore::with_path(&store_path).map_err(|err| {
anyhow!(
"failed to open dev secrets store {}: {err}",
store_path.display()
)
})?;
let seeds = load_seed_entries(bundle_root)?;
Ok(Self {
store,
store_path,
env: env.to_string(),
tenant: tenant.to_string(),
team: team.map(|v| v.to_string()),
seeds,
})
}
pub fn store_path(&self) -> &Path {
&self.store_path
}
pub fn store(&self) -> &DevStore {
&self.store
}
pub async fn ensure_pack_secrets(&self, pack_path: &Path, provider_id: &str) -> Result<()> {
let keys = load_secret_keys_from_pack(pack_path)?;
if keys.is_empty() {
return Ok(());
}
let mut missing = Vec::new();
for key in keys {
let uri = canonical_secret_uri(
&self.env,
&self.tenant,
self.team.as_deref(),
provider_id,
&key,
);
debug!(uri = %uri, provider = %provider_id, key = %key, "canonicalized secret requirement");
match self.store.get(&uri).await {
Ok(_) => continue,
Err(SecretError::NotFound { .. }) => {
let source = if self.seeds.contains_key(&uri) {
"seeds.yaml"
} else {
"placeholder"
};
debug!(uri = %uri, source, "seeding missing secret");
missing.push(
self.seeds
.get(&uri)
.cloned()
.unwrap_or_else(|| placeholder_entry(uri)),
);
}
Err(err) => {
return Err(anyhow!("failed to read secret {uri}: {err}"));
}
}
}
if missing.is_empty() {
return Ok(());
}
let report = apply_seed(
&self.store,
&SeedDoc { entries: missing },
ApplyOptions::default(),
)
.await;
if !report.failed.is_empty() {
return Err(anyhow!("failed to seed secrets: {:?}", report.failed));
}
Ok(())
}
}
fn load_seed_entries(bundle_root: &Path) -> Result<HashMap<String, SeedEntry>> {
for candidate in seed_paths(bundle_root) {
if candidate.exists() {
let contents = std::fs::read_to_string(&candidate)?;
let doc: SeedDoc = serde_yaml_bw::from_str(&contents)?;
return Ok(doc
.entries
.into_iter()
.map(|entry| (entry.uri.clone(), entry))
.collect());
}
}
Ok(HashMap::new())
}
fn seed_paths(bundle_root: &Path) -> [PathBuf; 2] {
[
bundle_root.join("seeds.yaml"),
bundle_root.join("state").join("seeds.yaml"),
]
}
fn placeholder_entry(uri: String) -> SeedEntry {
SeedEntry {
uri: uri.clone(),
format: SecretFormat::Text,
value: SeedValue::Text {
text: format!("placeholder for {uri}"),
},
description: Some("auto-applied placeholder".to_string()),
}
}
pub fn load_secret_keys_from_pack(pack_path: &Path) -> Result<Vec<String>> {
Ok(load_secret_requirements_from_pack(pack_path)?
.into_iter()
.map(|req| req.key)
.collect())
}
pub fn load_secret_requirements_from_pack(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
let file = std::fs::File::open(pack_path)?;
let mut archive = zip::ZipArchive::new(file)?;
for entry_name in &[
"assets/secret-requirements.json",
"assets/secret_requirements.json",
"secret-requirements.json",
"secret_requirements.json",
] {
match archive.by_name(entry_name) {
Ok(reader) => {
let reqs: Vec<PackSecretRequirement> = serde_json::from_reader(reader)?;
return Ok(dedup_requirements(reqs));
}
Err(zip::result::ZipError::FileNotFound) => continue,
Err(err) => return Err(err.into()),
}
}
let mut reqs = Vec::new();
for index in 0..archive.len() {
let name = {
let entry = archive.by_index(index)?;
entry.name().to_string()
};
if name != "manifest.cbor" && !name.ends_with(".manifest.cbor") {
continue;
}
let mut entry = archive.by_name(&name)?;
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut entry, &mut bytes)?;
let value: CborValue = serde_cbor::from_slice(&bytes)?;
collect_secret_requirements_from_cbor(&value, &mut reqs);
}
Ok(dedup_requirements(reqs))
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct PackSecretRequirement {
pub key: String,
#[serde(default = "default_required")]
pub required: bool,
#[serde(default)]
pub description: Option<String>,
}
fn default_required() -> bool {
true
}
fn dedup_requirements(reqs: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
let mut by_key = BTreeMap::new();
for req in reqs {
by_key.entry(req.key.clone()).or_insert(req);
}
by_key.into_values().collect()
}
fn collect_secret_requirements_from_cbor(value: &CborValue, out: &mut Vec<PackSecretRequirement>) {
match value {
CborValue::Array(values) => {
for value in values {
collect_secret_requirements_from_cbor(value, out);
}
}
CborValue::Map(map) => {
if let Some(req) = parse_secret_requirement_map(map) {
out.push(req);
}
for value in map.values() {
collect_secret_requirements_from_cbor(value, out);
}
}
_ => {}
}
}
fn parse_secret_requirement_map(
map: &BTreeMap<CborValue, CborValue>,
) -> Option<PackSecretRequirement> {
let key = map_get_text(map, "key")?;
let has_secret_shape = map.contains_key(&CborValue::Text("required".to_string()))
|| map.contains_key(&CborValue::Text("scope".to_string()))
|| map.contains_key(&CborValue::Text("format".to_string()))
|| map.contains_key(&CborValue::Text("description".to_string()));
if !has_secret_shape {
return None;
}
Some(PackSecretRequirement {
key,
required: map_get_bool(map, "required").unwrap_or(true),
description: map_get_text(map, "description"),
})
}
fn map_get_text(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<String> {
map.get(&CborValue::Text(key.to_string()))
.and_then(|value| match value {
CborValue::Text(text) => Some(text.clone()),
_ => None,
})
}
fn map_get_bool(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<bool> {
map.get(&CborValue::Text(key.to_string()))
.and_then(|value| match value {
CborValue::Bool(flag) => Some(*flag),
_ => None,
})
}
pub fn open_dev_store_for_env(bundle_root: &Path, env: &str) -> Result<DevStore> {
let store_path = ensure_path_for_env(bundle_root, env)?;
DevStore::with_path(&store_path).map_err(|err| {
anyhow!(
"failed to open dev secrets store {}: {err}",
store_path.display()
)
})
}
pub fn open_dev_store(bundle_root: &Path) -> Result<DevStore> {
let store_path = ensure_path(bundle_root)?;
DevStore::with_path(&store_path).map_err(|err| {
anyhow!(
"failed to open dev secrets store {}: {err}",
store_path.display()
)
})
}
#[cfg(test)]
pub(crate) mod test_support {
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, OnceLock};
static GUARDED: AtomicBool = AtomicBool::new(false);
pub(crate) fn store_access_is_guarded() -> bool {
GUARDED.load(Ordering::SeqCst)
}
struct GuardFlag;
impl GuardFlag {
fn set() -> Self {
GUARDED.store(true, Ordering::SeqCst);
Self
}
}
impl Drop for GuardFlag {
fn drop(&mut self) {
GUARDED.store(false, Ordering::SeqCst);
}
}
pub(crate) fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub(crate) struct StoreOverride {
_flag: GuardFlag,
_guard: MutexGuard<'static, ()>,
previous: Option<String>,
}
impl StoreOverride {
pub(crate) fn at(path: &Path) -> Self {
let guard = env_lock();
let previous = std::env::var(super::OVERRIDE_ENV).ok();
unsafe { std::env::set_var(super::OVERRIDE_ENV, path) };
Self {
_flag: GuardFlag::set(),
_guard: guard,
previous,
}
}
pub(crate) fn in_dir(dir: &Path) -> Self {
Self::at(&dir.join(".dev.secrets.env"))
}
}
pub(crate) struct IsolatedStore {
_override: StoreOverride,
_dir: tempfile::TempDir,
}
pub(crate) fn isolated_store() -> IsolatedStore {
let dir = tempfile::tempdir().expect("dev store isolation dir");
let guard = StoreOverride::in_dir(dir.path());
IsolatedStore {
_override: guard,
_dir: dir,
}
}
pub(crate) struct EnvLock(
#[allow(dead_code)] GuardFlag,
#[allow(dead_code)] MutexGuard<'static, ()>,
);
pub(crate) fn lock_env() -> EnvLock {
let guard = env_lock();
EnvLock(GuardFlag::set(), guard)
}
impl Drop for StoreOverride {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => unsafe { std::env::set_var(super::OVERRIDE_ENV, value) },
None => unsafe { std::env::remove_var(super::OVERRIDE_ENV) },
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use zip::write::SimpleFileOptions;
fn write_pack_with_secret_requirements(path: &Path, req_json: &str) -> anyhow::Result<()> {
let file = std::fs::File::create(path)?;
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"assets/secret-requirements.json",
SimpleFileOptions::default(),
)?;
zip.write_all(req_json.as_bytes())?;
zip.finish()?;
Ok(())
}
#[test]
fn env_store_path_has_expected_shape() {
if let Some(path) = env_store_dev_secrets_path("some-env") {
assert!(
path.ends_with("environments/some-env/.greentic/dev/.dev.secrets.env"),
"unexpected env-store path: {}",
path.display()
);
}
}
fn write_bundle_local_store(bundle: &Path) -> PathBuf {
let path = bundle.join(STORE_RELATIVE);
std::fs::create_dir_all(path.parent().expect("store parent")).expect("store dir");
std::fs::write(&path, "KEY=value\n").expect("write store");
path
}
#[test]
fn write_path_for_env_and_default_path_target_the_dev_store() {
let _env_lock = crate::secrets::test_support::lock_env();
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
let write = write_path_for_env(&bundle, "some-env");
assert!(
write.ends_with(STORE_RELATIVE),
"unexpected write path: {}",
write.display()
);
let default = default_path(&bundle);
assert!(
default.ends_with(STORE_RELATIVE),
"unexpected default path: {}",
default.display()
);
}
#[test]
fn write_path_for_env_targets_the_env_store_not_bundle_local() {
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
let Some(env_store) = env_store_dev_secrets_path("guard-env") else {
return; };
assert_eq!(
write_path_for_env(&bundle, "guard-env"),
env_store,
"env-explicit write must target the env store",
);
assert!(
!write_path_for_env(&bundle, "guard-env").starts_with(&bundle),
"env-explicit write must never land in the bundle-local store",
);
}
#[test]
fn find_existing_locates_a_bundle_local_dev_store() {
let _env_lock = test_support::lock_env();
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
let store = bundle.join(STORE_RELATIVE);
std::fs::create_dir_all(store.parent().expect("store parent")).expect("dev dir");
std::fs::write(&store, "KEY=value\n").expect("write store");
let found = find_existing(&bundle).expect("find dev store");
assert!(
found.exists(),
"found path should exist: {}",
found.display()
);
assert!(
found.ends_with(STORE_RELATIVE),
"unexpected found path: {}",
found.display()
);
}
#[test]
fn find_existing_with_override_prefers_override() {
let _env_lock = test_support::lock_env();
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
std::fs::create_dir_all(&bundle).expect("bundle dir");
let override_file = temp.path().join("custom.env");
std::fs::write(&override_file, "KEY=value\n").expect("write override");
let found = find_existing_with_override(&bundle, Some(&override_file));
assert_eq!(found.as_deref(), Some(override_file.as_path()));
}
#[test]
fn find_existing_with_override_falls_back_when_override_is_absent() {
let _env_lock = test_support::lock_env();
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
std::fs::create_dir_all(&bundle).expect("bundle dir");
write_bundle_local_store(&bundle);
let missing_override = temp.path().join("does-not-exist.env");
for override_arg in [None, Some(missing_override.as_path())] {
let found = find_existing_with_override(&bundle, override_arg)
.expect("existing store must be discovered");
assert!(found.exists(), "resolved store does not exist: {found:?}");
assert!(
found.ends_with(".dev.secrets.env"),
"unexpected store file: {found:?}"
);
}
}
#[test]
fn find_existing_discovers_the_bundle_local_store() {
let _env_lock = test_support::lock_env();
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
std::fs::create_dir_all(&bundle).expect("bundle dir");
write_bundle_local_store(&bundle);
let found = find_existing(&bundle).expect("existing store must be discovered");
assert!(found.exists(), "resolved store does not exist: {found:?}");
}
#[test]
fn read_candidate_paths_ends_with_bundle_local_candidates() {
let _env_lock = test_support::lock_env();
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
let candidates = read_candidate_paths(&bundle);
assert!(
candidates.len() == 2 || candidates.len() == 3,
"unexpected candidates: {candidates:?}"
);
assert_eq!(
&candidates[candidates.len() - 2..],
&[
bundle.join(STORE_RELATIVE),
bundle.join(STORE_STATE_RELATIVE)
]
);
}
#[test]
fn write_path_for_env_always_targets_a_dev_store_file() {
let _env_lock = test_support::lock_env();
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
let path = write_path_for_env(&bundle, "some-env");
assert!(
path.ends_with(STORE_RELATIVE),
"unexpected write path: {}",
path.display()
);
}
#[test]
fn default_path_creates_nothing() {
let _env_lock = test_support::lock_env();
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
let path = default_path(&bundle);
assert!(
path.ends_with(".dev.secrets.env"),
"unexpected default path: {}",
path.display()
);
assert!(
!bundle.exists(),
"default_path must not touch the filesystem"
);
}
#[test]
fn load_secret_keys_from_pack_reads_requirements() {
let temp = tempfile::tempdir().expect("tempdir");
let pack = temp.path().join("provider.gtpack");
write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"},{"key":"API_SECRET"}]"#)
.expect("write pack");
let keys = load_secret_keys_from_pack(&pack).expect("load keys");
assert_eq!(
keys,
vec!["API_SECRET".to_string(), "BOT_TOKEN".to_string()]
);
}
#[test]
fn load_secret_keys_from_pack_reads_cbor_manifest_requirements() {
let temp = tempfile::tempdir().expect("tempdir");
let pack = temp.path().join("provider.gtpack");
let file = std::fs::File::create(&pack).expect("create pack");
let mut zip = zip::ZipWriter::new(file);
zip.start_file("manifest.cbor", SimpleFileOptions::default())
.expect("start entry");
let manifest = serde_json::json!({
"components": [
{
"host": {
"secrets": {
"required": [
{
"key": "auth.param.get_weather.key",
"required": true,
"description": "Weather key",
"scope": {"env": "runtime", "tenant": "runtime"},
"format": "text"
}
]
}
}
}
]
});
let bytes = serde_cbor::to_vec(&manifest).expect("serialize cbor");
zip.write_all(&bytes).expect("write manifest");
zip.finish().expect("finish zip");
let keys = load_secret_keys_from_pack(&pack).expect("load keys");
assert_eq!(keys, vec!["auth.param.get_weather.key".to_string()]);
let reqs = load_secret_requirements_from_pack(&pack).expect("load reqs");
assert_eq!(reqs.len(), 1);
assert_eq!(reqs[0].description.as_deref(), Some("Weather key"));
}
#[test]
fn load_secret_keys_from_pack_returns_empty_without_requirements() {
let temp = tempfile::tempdir().expect("tempdir");
let pack = temp.path().join("provider.gtpack");
let file = std::fs::File::create(&pack).expect("create pack");
let mut zip = zip::ZipWriter::new(file);
zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
.expect("start entry");
zip.write_all(b"questions: []\n").expect("write setup");
zip.finish().expect("finish zip");
let keys = load_secret_keys_from_pack(&pack).expect("load keys");
assert!(keys.is_empty());
}
#[test]
fn cbor_manifest_scan_skips_maps_that_are_not_secret_requirements() {
let temp = tempfile::tempdir().expect("tempdir");
let pack = temp.path().join("provider.gtpack");
let file = std::fs::File::create(&pack).expect("create pack");
let mut zip = zip::ZipWriter::new(file);
zip.start_file("manifest.cbor", SimpleFileOptions::default())
.expect("start entry");
let manifest = serde_json::json!({
"routes": [{"key": "not-a-secret", "target": "node-a"}],
"indexed": [{"key": 7, "required": true}],
"secrets": [{"key": "loose.secret", "required": "yes"}],
});
let bytes = serde_cbor::to_vec(&manifest).expect("serialize cbor");
zip.write_all(&bytes).expect("write manifest");
zip.finish().expect("finish zip");
let reqs = load_secret_requirements_from_pack(&pack).expect("load reqs");
assert_eq!(reqs.len(), 1, "unexpected requirements: {reqs:?}");
assert_eq!(reqs[0].key, "loose.secret");
assert!(reqs[0].required, "non-bool `required` must default to true");
assert!(reqs[0].description.is_none());
}
#[tokio::test]
async fn ensure_pack_secrets_is_a_no_op_without_requirements() {
let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
std::fs::create_dir_all(&bundle).expect("bundle dir");
let pack = temp.path().join("provider.gtpack");
write_pack_with_secret_requirements(&pack, "[]").expect("pack");
let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
setup
.ensure_pack_secrets(&pack, "messaging-telegram")
.await
.expect("ensure secrets");
assert!(
setup
.store_path()
.parent()
.is_some_and(|parent| parent.is_dir()),
"store parent must be created: {}",
setup.store_path().display()
);
assert!(
setup.store_path().ends_with(".dev.secrets.env"),
"unexpected store path: {}",
setup.store_path().display()
);
}
#[tokio::test]
async fn ensure_pack_secrets_is_a_noop_without_requirements() {
let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
std::fs::create_dir_all(&bundle).expect("bundle dir");
let pack = temp.path().join("provider.gtpack");
let file = std::fs::File::create(&pack).expect("create pack");
let mut zip = zip::ZipWriter::new(file);
zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
.expect("start entry");
zip.write_all(b"questions: []\n").expect("write setup");
zip.finish().expect("finish zip");
let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", None).expect("setup");
assert!(setup.store_path().ends_with(".dev.secrets.env"));
setup
.ensure_pack_secrets(&pack, "messaging-telegram")
.await
.expect("noop for empty requirements");
}
#[tokio::test]
async fn ensure_pack_secrets_seeds_placeholders_for_missing_keys() {
let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
std::fs::create_dir_all(&bundle).expect("bundle dir");
let pack = temp.path().join("provider.gtpack");
write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
setup
.ensure_pack_secrets(&pack, "messaging-telegram")
.await
.expect("ensure secrets");
let uri = canonical_secret_uri(
"dev",
"tenant-a",
Some("core"),
"messaging-telegram",
"BOT_TOKEN",
);
let value = setup.store().get(&uri).await.expect("seeded value");
let value = String::from_utf8(value).expect("utf8");
assert!(
value.contains("placeholder for secrets://"),
"unexpected placeholder value: {value}"
);
}
#[tokio::test]
async fn ensure_pack_secrets_uses_seed_values_when_available() {
let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
let temp = tempfile::tempdir().expect("tempdir");
let bundle = temp.path().join("bundle");
std::fs::create_dir_all(&bundle).expect("bundle dir");
let seed_uri = canonical_secret_uri(
"dev",
"tenant-a",
Some("core"),
"messaging-telegram",
"BOT_TOKEN",
);
let seeds_yaml = serde_yaml_bw::to_string(&SeedDoc {
entries: vec![SeedEntry {
uri: seed_uri.clone(),
format: SecretFormat::Text,
value: SeedValue::Text {
text: "seeded-secret".to_string(),
},
description: Some("test seed".to_string()),
}],
})
.expect("serialize seeds");
std::fs::write(bundle.join("seeds.yaml"), seeds_yaml).expect("write seeds");
let pack = temp.path().join("provider.gtpack");
write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
setup
.ensure_pack_secrets(&pack, "messaging-telegram")
.await
.expect("ensure secrets");
let value = setup.store().get(&seed_uri).await.expect("seeded value");
let value = String::from_utf8(value).expect("utf8");
assert_eq!(value, "seeded-secret");
}
}