Skip to main content

greentic_setup/qa/
persist.rs

1//! Persist config and secrets from QA apply-answers output.
2//!
3//! After a provider's `apply-answers` op returns a config object, this module:
4//! - Writes every visible answer to the dev secrets store under
5//!   `secrets://<env>/<tenant>/<team>/<provider>/<key>` (legacy universal
6//!   write — WASM components have historically read both secret and
7//!   non-secret config values through the secrets API).
8//! - Emits a new sibling `pack-config-input.v1` file via
9//!   [`emit_pack_config_input`] when the wizard scope is known. This is the
10//!   C7 producer for the `pack-config.v1.non_secret` channel: the greentic-
11//!   deployer picks up the file at revision-create, stamps the active
12//!   `revision_id`, and writes the final `pack-config.v1` that
13//!   [`RuntimeConfigHost`](https://docs.rs/greentic-interfaces-wasmtime)
14//!   reads (C4). The universal DevStore write stays alive for one release as
15//!   the C4.2 compatibility shim — runtime reads of non-secret keys fall back
16//!   to it with a once-per-process deprecation warning.
17//! - Provides filtering to separate secret from non-secret fields.
18
19use 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
32/// Extract all question fields from the QA config output and write them to the dev store.
33///
34/// All fields are persisted (not just secrets) because WASM components read
35/// both secret and non-secret config values via the secrets API.
36///
37/// Returns a list of keys that were persisted.
38pub 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    // Compute visibility to skip invisible/conditional questions.
48    let visibility = resolve_visibility(form_spec, config, VisibilityMode::Visible);
49
50    let visible_question_ids: Vec<&str> = form_spec
51        .questions
52        .iter()
53        .filter(|q| visibility.get(&q.id).copied().unwrap_or(true))
54        .map(|q| q.id.as_str())
55        .collect();
56    if visible_question_ids.is_empty() {
57        return Ok(vec![]);
58    }
59
60    let Some(config_map) = config.as_object() else {
61        return Ok(vec![]);
62    };
63
64    let mut entries = Vec::new();
65    let mut saved_keys = Vec::new();
66
67    for &key in &visible_question_ids {
68        if let Some(value) = config_map.get(key) {
69            let text = value_to_text(value);
70            if text.is_empty() || text == "null" {
71                continue;
72            }
73            let uri = canonical_secret_uri(env, tenant, team, provider_id, key);
74            entries.push(SeedEntry {
75                uri,
76                format: SecretFormat::Text,
77                value: SeedValue::Text { text },
78                description: Some(format!("from QA setup for {provider_id}")),
79            });
80            saved_keys.push(key.to_string());
81        }
82    }
83
84    if entries.is_empty() {
85        return Ok(vec![]);
86    }
87
88    let report = apply_seed(store, &SeedDoc { entries }, ApplyOptions::default()).await;
89    if !report.failed.is_empty() {
90        return Err(anyhow::anyhow!(
91            "failed to persist {} secret(s): {:?}",
92            report.failed.len(),
93            report.failed
94        ));
95    }
96
97    Ok(saved_keys)
98}
99
100/// Remove secret fields from a config object.
101pub fn filter_secrets(config: &Value, secret_ids: &[&str]) -> Value {
102    let Some(map) = config.as_object() else {
103        return config.clone();
104    };
105    let filtered: JsonMap<String, Value> = map
106        .iter()
107        .filter(|(key, _)| !secret_ids.contains(&key.as_str()))
108        .map(|(k, v)| (k.clone(), v.clone()))
109        .collect();
110    Value::Object(filtered)
111}
112
113/// Persist all config values as secrets without requiring a FormSpec.
114///
115/// Used by `demo start --setup-input` where the QA form spec may not
116/// be available but WASM components still read config values via the secrets API.
117///
118/// Also reads the pack's `secret-requirements.json` (if a `pack_path` is
119/// provided) and seeds aliases so that WASM components that look up secrets by
120/// their canonical requirement key can find the value even when the answers
121/// file uses a shorter key.
122pub async fn persist_all_config_as_secrets(
123    bundle_root: &Path,
124    env: &str,
125    tenant: &str,
126    team: Option<&str>,
127    provider_id: &str,
128    config: &Value,
129    pack_path: Option<&Path>,
130) -> Result<Vec<String>> {
131    let Some(config_map) = config.as_object() else {
132        return Ok(vec![]);
133    };
134    if config_map.is_empty() {
135        return Ok(vec![]);
136    }
137
138    let store_path = crate::secrets::ensure_path(bundle_root)?;
139    let store = crate::secrets::open_dev_store(bundle_root)?;
140
141    let mut entries = Vec::new();
142    let mut saved_keys = Vec::new();
143
144    for (key, value) in config_map {
145        let text = value_to_text(value);
146        if text.is_empty() || text == "null" {
147            continue;
148        }
149        let uri = canonical_secret_uri(env, tenant, team, provider_id, key);
150        entries.push(SeedEntry {
151            uri,
152            format: SecretFormat::Text,
153            value: SeedValue::Text { text },
154            description: Some(format!("from setup-input for {provider_id}")),
155        });
156        saved_keys.push(key.to_string());
157    }
158
159    // Seed aliases from secret-requirements.json so WASM components can find
160    // secrets by their canonical requirement key (e.g. WEBEX_BOT_TOKEN →
161    // webex_bot_token) even when the answers file uses a shorter key (bot_token).
162    if let Some(pp) = pack_path {
163        seed_secret_requirement_aliases(
164            &mut entries,
165            config_map,
166            env,
167            tenant,
168            team,
169            provider_id,
170            pp,
171        );
172    }
173
174    if entries.is_empty() {
175        return Ok(vec![]);
176    }
177
178    tracing::info!(
179        provider_id,
180        env,
181        tenant,
182        team = team.unwrap_or("default"),
183        store_path = %store_path.display(),
184        entry_count = entries.len(),
185        uris = ?entries.iter().map(|e| e.uri.as_str()).collect::<Vec<_>>(),
186        "setup secrets persist: applying seed entries"
187    );
188
189    let verify_uris: Vec<String> = entries.iter().map(|e| e.uri.clone()).collect();
190    let report = apply_seed(&store, &SeedDoc { entries }, ApplyOptions::default()).await;
191    if !report.failed.is_empty() {
192        tracing::warn!(
193            provider_id,
194            env,
195            tenant,
196            team = team.unwrap_or("default"),
197            store_path = %store_path.display(),
198            failed = ?report.failed,
199            "setup secrets persist: apply_seed reported failures"
200        );
201        return Err(anyhow::anyhow!(
202            "failed to persist {} secret(s): {:?}",
203            report.failed.len(),
204            report.failed
205        ));
206    }
207
208    // Read-after-write verification so handoff issues are visible in setup logs.
209    let mut verify_missing = Vec::new();
210    for uri in &verify_uris {
211        if store.get(uri).await.is_err() {
212            verify_missing.push(uri.clone());
213        }
214    }
215    if verify_missing.is_empty() {
216        tracing::info!(
217            provider_id,
218            env,
219            tenant,
220            team = team.unwrap_or("default"),
221            store_path = %store_path.display(),
222            verified = report.ok,
223            "setup secrets persist: post-write verification succeeded"
224        );
225    } else {
226        tracing::warn!(
227            provider_id,
228            env,
229            tenant,
230            team = team.unwrap_or("default"),
231            store_path = %store_path.display(),
232            missing_uris = ?verify_missing,
233            "setup secrets persist: post-write verification found missing entries"
234        );
235    }
236
237    Ok(saved_keys)
238}
239
240/// Convenience function to persist both secrets and config from QA results.
241///
242/// Creates a `DevStore` from the bundle root and persists every answer there
243/// (legacy universal-write — see module docs). Additionally emits a
244/// `pack-config-input.v1` file (C7) so the deployer can populate the
245/// `pack-config.v1.non_secret` channel at revision-create.
246#[allow(clippy::too_many_arguments)]
247pub async fn persist_qa_results(
248    bundle_root: &Path,
249    tenant: &str,
250    team: Option<&str>,
251    provider_id: &str,
252    config: &Value,
253    form_spec: &FormSpec,
254) -> Result<Vec<String>> {
255    let env = crate::resolve_env(None);
256    let store = crate::secrets::open_dev_store(bundle_root)?;
257
258    let keys =
259        persist_qa_secrets(&store, &env, tenant, team, provider_id, config, form_spec).await?;
260
261    let bundle_id = infer_bundle_id(bundle_root);
262    if let Err(err) = emit_pack_config_input(
263        bundle_root,
264        &env,
265        &bundle_id,
266        provider_id,
267        config,
268        form_spec,
269    ) {
270        // Soft-fail: the C4.2 compatibility shim still serves these keys
271        // from DevStore (already populated above), so a wizard run does not
272        // regress on emit failure. The deployer (C7 PR4) will report
273        // missing-input at revision-create.
274        tracing::warn!(
275            provider_id,
276            env = %env,
277            bundle_id = %bundle_id,
278            bundle_root = %bundle_root.display(),
279            error = %err,
280            "pack-config-input emission failed; runtime falls back to legacy DevStore reads via C4.2 compat shim",
281        );
282    }
283
284    Ok(keys)
285}
286
287/// Re-export of [`crate::bundle::infer_bundle_id`] for callers that don't
288/// have an explicit `bundle_id` field in their context.
289pub(crate) fn infer_bundle_id(root: &Path) -> String {
290    crate::bundle::infer_bundle_id(root)
291}
292
293/// OAuth authorization stub.
294///
295/// Prints the authorization URL and returns `None`. Placeholder for future
296/// `greentic-oauth` integration.
297pub fn oauth_authorize_stub(provider_id: &str, auth_url: Option<&str>) -> Option<String> {
298    if let Some(url) = auth_url {
299        println!("[oauth] Authorize {provider_id} at: {url}");
300        println!("[oauth] After authorizing, re-run setup to complete configuration.");
301    } else {
302        println!("[oauth] Provider {provider_id} requires OAuth authorization.");
303        println!("[oauth] OAuth integration is not yet implemented.");
304    }
305    None
306}
307
308// ── Alias seeding ───────────────────────────────────────────────────────────
309
310/// Read `assets/secret-requirements.json` from a pack and seed alias entries
311/// for any requirement key that differs from the answers key after
312/// canonicalization.
313fn seed_secret_requirement_aliases(
314    entries: &mut Vec<SeedEntry>,
315    config_map: &JsonMap<String, Value>,
316    env: &str,
317    tenant: &str,
318    team: Option<&str>,
319    provider_id: &str,
320    pack_path: &Path,
321) {
322    let reqs = match read_secret_requirements(pack_path) {
323        Ok(r) => r,
324        Err(_) => return,
325    };
326    let normalize = crate::secret_name::canonical_secret_name;
327    let existing_keys: std::collections::HashSet<String> = entries
328        .iter()
329        .filter_map(|e| e.uri.rsplit('/').next().map(String::from))
330        .collect();
331
332    for req in &reqs {
333        let canonical_req_key = normalize(&req.key);
334        if existing_keys.contains(&canonical_req_key) {
335            continue;
336        }
337        let matched_value = config_map.iter().find_map(|(cfg_key, cfg_val)| {
338            let norm_cfg = normalize(cfg_key);
339            if canonical_req_key.ends_with(&norm_cfg) {
340                let text = value_to_text(cfg_val);
341                if text.is_empty() || text == "null" {
342                    None
343                } else {
344                    Some(text)
345                }
346            } else {
347                None
348            }
349        });
350        if let Some(text) = matched_value {
351            let uri = canonical_secret_uri(env, tenant, team, provider_id, &canonical_req_key);
352            entries.push(SeedEntry {
353                uri,
354                format: SecretFormat::Text,
355                value: SeedValue::Text { text },
356                description: Some(format!("alias from {} for {provider_id}", req.key)),
357            });
358        }
359    }
360}
361
362fn read_secret_requirements(
363    pack_path: &Path,
364) -> Result<Vec<crate::secrets::PackSecretRequirement>> {
365    crate::secrets::load_secret_requirements_from_pack(pack_path)
366}
367
368fn value_to_text(value: &Value) -> String {
369    match value {
370        Value::String(s) => s.clone(),
371        other => other.to_string(),
372    }
373}
374
375// ── pack-config-input.v1 emitter (C7) ──────────────────────────────────────
376
377/// Schema tag for the wizard-emitted intermediate file consumed by the
378/// greentic-deployer at revision-create.
379pub const PACK_CONFIG_INPUT_SCHEMA: &str = "greentic.pack-config-input.v1";
380
381/// Directory under `bundle_root` where wizard-emitted pack-config inputs land.
382/// The deployer joins on `<bundle_root>/<PACK_CONFIG_INPUT_DIR>/<pack_id>.json`
383/// at revision-create.
384pub const PACK_CONFIG_INPUT_DIR: &str = "state/pack-configs";
385
386/// Wizard-emitted intermediate file the deployer picks up at revision-create
387/// (C7). The deployer stamps the active `revision_id` and writes the final
388/// `pack-config.v1` referenced by `pack_config_refs` in `runtime-config.v1`.
389/// We keep `revision_id` OUT of this shape on purpose: revisions are minted
390/// by the deployer, not the wizard.
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct PackConfigInput {
393    pub schema: String,
394    pub pack_id: String,
395    pub env_id: String,
396    pub bundle_id: String,
397    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
398    pub non_secret: BTreeMap<String, Value>,
399    /// `secret://<env>/<bundle>/<pack>/<question>` URIs (kept as plain
400    /// strings here — `greentic-deploy-spec::SecretRef` validates at the
401    /// deployer side when it materializes the final `pack-config.v1`).
402    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
403    pub secret_refs: BTreeMap<String, String>,
404}
405
406/// Emit a `pack-config-input.v1` file at
407/// `<bundle_root>/state/pack-configs/<pack_id>.json` carrying the FormSpec-
408/// split of one provider's QA answers (C7). Idempotent: overwrites in place
409/// so re-running the wizard produces the same on-disk shape.
410///
411/// Secret-marked answers are recorded as `secret://<env>/<bundle>/<pack>/<key>`
412/// URI references (no plaintext); non-secret answers stay inline. Empty
413/// `config` is a no-op (no file written).
414///
415/// Calling this directly from greentic-setup callers gives them a stable
416/// public API surface; the existing `persist_qa_secrets` /
417/// `persist_all_config_as_secrets` keep their universal DevStore writes as
418/// the C4.2 compatibility-shim path until the deployer is fully wired and a
419/// follow-up drops the redundant writes.
420pub fn emit_pack_config_input(
421    bundle_root: &Path,
422    env_id: &str,
423    bundle_id: &str,
424    pack_id: &str,
425    config: &Value,
426    form_spec: &FormSpec,
427) -> Result<Option<std::path::PathBuf>> {
428    validate_segment("env_id", env_id)?;
429    validate_segment("bundle_id", bundle_id)?;
430    validate_segment("pack_id", pack_id)?;
431
432    let Some(config_map) = config.as_object() else {
433        return Ok(None);
434    };
435    if config_map.is_empty() {
436        return Ok(None);
437    }
438
439    // Apply the same visibility filter that `persist_qa_secrets` uses so
440    // that conditionally-invisible answers do not leak into the
441    // pack-config-input file (and from there into runtime config).
442    let visibility = resolve_visibility(form_spec, config, VisibilityMode::Visible);
443
444    let secret_ids: std::collections::HashSet<&str> = form_spec
445        .questions
446        .iter()
447        .filter(|q| q.secret)
448        .map(|q| q.id.as_str())
449        .collect();
450
451    let visible_ids: std::collections::HashSet<&str> = form_spec
452        .questions
453        .iter()
454        .filter(|q| visibility.get(&q.id).copied().unwrap_or(true))
455        .map(|q| q.id.as_str())
456        .collect();
457
458    let mut non_secret = BTreeMap::new();
459    let mut secret_refs = BTreeMap::new();
460    for (key, value) in config_map {
461        // Skip keys that are not visible according to the form spec's
462        // visibility rules (matches `persist_qa_secrets` behavior).
463        if !visible_ids.contains(key.as_str()) {
464            continue;
465        }
466        let text = value_to_text(value);
467        if text.is_empty() || text == "null" {
468            continue;
469        }
470        if secret_ids.contains(key.as_str()) {
471            validate_segment("question.id", key)?;
472            let uri = format!("secret://{env_id}/{bundle_id}/{pack_id}/{key}");
473            secret_refs.insert(key.clone(), uri);
474        } else {
475            non_secret.insert(key.clone(), value.clone());
476        }
477    }
478
479    if non_secret.is_empty() && secret_refs.is_empty() {
480        return Ok(None);
481    }
482
483    let input = PackConfigInput {
484        schema: PACK_CONFIG_INPUT_SCHEMA.to_string(),
485        pack_id: pack_id.to_string(),
486        env_id: env_id.to_string(),
487        bundle_id: bundle_id.to_string(),
488        non_secret,
489        secret_refs,
490    };
491
492    let dir = bundle_root.join(PACK_CONFIG_INPUT_DIR);
493    std::fs::create_dir_all(&dir)
494        .with_context(|| format!("create pack-config-input dir {}", dir.display()))?;
495    let path = dir.join(format!("{pack_id}.json"));
496    let body = serde_json::to_string_pretty(&input).context("serialize pack-config-input.v1")?;
497    std::fs::write(&path, format!("{body}\n"))
498        .with_context(|| format!("write pack-config-input {}", path.display()))?;
499
500    tracing::debug!(
501        pack_id,
502        env_id,
503        bundle_id,
504        non_secret_count = input.non_secret.len(),
505        secret_ref_count = input.secret_refs.len(),
506        path = %path.display(),
507        "wizard emitted pack-config-input.v1 (C7) for deployer pickup",
508    );
509    Ok(Some(path))
510}
511
512/// Reject empty or `/`-bearing identifiers — these would silently corrupt the
513/// `secret://<env>/<bundle>/<pack>/<question>` path structure or the
514/// `<dir>/<pack_id>.json` file path.
515fn validate_segment(label: &str, value: &str) -> Result<()> {
516    if value.is_empty() {
517        anyhow::bail!("{label} must not be empty for pack-config-input emission");
518    }
519    if value.contains('/') {
520        anyhow::bail!(
521            "{label} `{value}` contains '/' which would corrupt the pack-config-input layout"
522        );
523    }
524    if value == "." || value == ".." {
525        anyhow::bail!(
526            "{label} `{value}` is a relative path component and would corrupt the pack-config-input layout"
527        );
528    }
529    Ok(())
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::secrets::open_dev_store;
536    use greentic_secrets_lib::SecretsStore;
537    use qa_spec::{QuestionSpec, QuestionType};
538    use serde_json::json;
539    use std::io::Write;
540    use std::path::Path;
541    use zip::write::SimpleFileOptions;
542
543    fn make_form_spec(questions: Vec<QuestionSpec>) -> FormSpec {
544        FormSpec {
545            id: "test".into(),
546            title: "Test".into(),
547            version: "1.0.0".into(),
548            description: None,
549            presentation: None,
550            progress_policy: None,
551            secrets_policy: None,
552            store: vec![],
553            validations: vec![],
554            includes: vec![],
555            questions,
556        }
557    }
558
559    fn question(id: &str, secret: bool) -> QuestionSpec {
560        QuestionSpec {
561            id: id.into(),
562            kind: QuestionType::String,
563            title: id.into(),
564            title_i18n: None,
565            description: None,
566            description_i18n: None,
567            required: false,
568            choices: None,
569            default_value: None,
570            secret,
571            visible_if: None,
572            constraint: None,
573            list: None,
574            computed: None,
575            policy: Default::default(),
576            computed_overridable: false,
577        }
578    }
579
580    #[test]
581    fn filters_out_secret_fields() {
582        let config = json!({
583            "enabled": true,
584            "bot_token": "secret123",
585            "public_url": "https://example.com"
586        });
587        let secret_ids = vec!["bot_token"];
588        let filtered = filter_secrets(&config, &secret_ids);
589        assert!(filtered.get("enabled").is_some());
590        assert!(filtered.get("public_url").is_some());
591        assert!(filtered.get("bot_token").is_none());
592    }
593
594    #[test]
595    fn no_secrets_returns_full_config() {
596        let config = json!({"enabled": true, "url": "https://example.com"});
597        let filtered = filter_secrets(&config, &[]);
598        assert_eq!(filtered, config);
599    }
600
601    #[test]
602    fn identifies_secret_questions() {
603        let spec = make_form_spec(vec![
604            question("enabled", false),
605            question("bot_token", true),
606            question("api_secret", true),
607            question("url", false),
608        ]);
609        let secret_ids: Vec<&str> = spec
610            .questions
611            .iter()
612            .filter(|q| q.secret)
613            .map(|q| q.id.as_str())
614            .collect();
615        assert_eq!(secret_ids, vec!["bot_token", "api_secret"]);
616    }
617
618    fn write_pack_with_secret_requirements(path: &Path, req_json: &str) {
619        let file = std::fs::File::create(path).expect("create pack");
620        let mut zip = zip::ZipWriter::new(file);
621        zip.start_file(
622            "assets/secret-requirements.json",
623            SimpleFileOptions::default(),
624        )
625        .expect("start entry");
626        zip.write_all(req_json.as_bytes()).expect("write reqs");
627        zip.finish().expect("finish zip");
628    }
629
630    #[tokio::test]
631    async fn persist_qa_secrets_persists_visible_non_empty_values() {
632        let temp = tempfile::tempdir().expect("tempdir");
633        let store = open_dev_store(temp.path()).expect("open dev store");
634        let spec = make_form_spec(vec![question("token", true), question("enabled", false)]);
635        let config = json!({
636            "token": "abc123",
637            "enabled": true,
638            "ignored": "not-in-form",
639            "empty": ""
640        });
641
642        let saved = persist_qa_secrets(
643            &store,
644            "dev",
645            "tenant-a",
646            Some("core"),
647            "messaging-telegram",
648            &config,
649            &spec,
650        )
651        .await
652        .expect("persist");
653        assert_eq!(saved, vec!["token".to_string(), "enabled".to_string()]);
654
655        let token_uri = crate::canonical_secret_uri(
656            "dev",
657            "tenant-a",
658            Some("core"),
659            "messaging-telegram",
660            "token",
661        );
662        let enabled_uri = crate::canonical_secret_uri(
663            "dev",
664            "tenant-a",
665            Some("core"),
666            "messaging-telegram",
667            "enabled",
668        );
669        let token_value =
670            String::from_utf8(store.get(&token_uri).await.expect("token")).expect("token utf8");
671        let enabled_value = String::from_utf8(store.get(&enabled_uri).await.expect("enabled"))
672            .expect("enabled utf8");
673        assert_eq!(token_value, "abc123");
674        assert_eq!(enabled_value, "true");
675    }
676
677    #[tokio::test]
678    async fn persist_all_config_as_secrets_seeds_aliases_from_requirements() {
679        let temp = tempfile::tempdir().expect("tempdir");
680        let bundle_root = temp.path();
681        let pack = bundle_root.join("messaging-webex.gtpack");
682        write_pack_with_secret_requirements(&pack, r#"[{"key":"WEBEX_BOT_TOKEN"}]"#);
683
684        let config = json!({
685            "bot_token": "xyz"
686        });
687        let saved = persist_all_config_as_secrets(
688            bundle_root,
689            "dev",
690            "tenant-a",
691            Some("core"),
692            "messaging-webex",
693            &config,
694            Some(&pack),
695        )
696        .await
697        .expect("persist all");
698        assert_eq!(saved, vec!["bot_token".to_string()]);
699
700        let store = open_dev_store(bundle_root).expect("open store");
701        let base_uri = crate::canonical_secret_uri(
702            "dev",
703            "tenant-a",
704            Some("core"),
705            "messaging-webex",
706            "bot_token",
707        );
708        let alias_uri = crate::canonical_secret_uri(
709            "dev",
710            "tenant-a",
711            Some("core"),
712            "messaging-webex",
713            "WEBEX_BOT_TOKEN",
714        );
715        let base_value =
716            String::from_utf8(store.get(&base_uri).await.expect("base")).expect("base utf8");
717        let alias_value =
718            String::from_utf8(store.get(&alias_uri).await.expect("alias")).expect("alias utf8");
719        assert_eq!(base_value, "xyz");
720        assert_eq!(alias_value, "xyz");
721    }
722
723    #[test]
724    fn oauth_authorize_stub_returns_none() {
725        assert!(
726            oauth_authorize_stub("messaging-slack", Some("https://auth.example.com")).is_none()
727        );
728        assert!(oauth_authorize_stub("messaging-slack", None).is_none());
729    }
730
731    // ── C7: pack-config-input.v1 emitter ──────────────────────────────────
732
733    /// Secrets land as `secret://` URI refs (no plaintext); non-secrets stay
734    /// inline. Empty config → no file written.
735    #[test]
736    fn emit_pack_config_input_splits_secret_vs_non_secret() {
737        let tmp = tempfile::TempDir::new().expect("tempdir");
738        let root = tmp.path();
739        let form = make_form_spec(vec![
740            question("enabled", false),
741            question("bot_token", true),
742            question("public_url", false),
743        ]);
744        let config = json!({
745            "enabled": true,
746            "bot_token": "shhh",
747            "public_url": "https://example.com",
748        });
749        let path =
750            emit_pack_config_input(root, "local", "test-bundle", "provider-a", &config, &form)
751                .expect("emit")
752                .expect("path");
753        assert!(path.exists());
754        let bytes = std::fs::read(&path).expect("read");
755        let parsed: PackConfigInput = serde_json::from_slice(&bytes).expect("parse");
756        assert_eq!(parsed.schema, PACK_CONFIG_INPUT_SCHEMA);
757        assert_eq!(parsed.pack_id, "provider-a");
758        assert_eq!(parsed.env_id, "local");
759        assert_eq!(parsed.bundle_id, "test-bundle");
760        assert_eq!(
761            parsed.non_secret.get("enabled"),
762            Some(&Value::Bool(true)),
763            "non-secret inline"
764        );
765        assert_eq!(
766            parsed.non_secret.get("public_url"),
767            Some(&Value::String("https://example.com".into())),
768        );
769        assert!(
770            !parsed.non_secret.contains_key("bot_token"),
771            "secret must not be in non_secret"
772        );
773        assert_eq!(
774            parsed.secret_refs.get("bot_token").map(String::as_str),
775            Some("secret://local/test-bundle/provider-a/bot_token"),
776            "secret recorded as URI ref"
777        );
778        // No plaintext for the secret anywhere in the file.
779        let body = String::from_utf8(bytes).expect("utf8");
780        assert!(
781            !body.contains("shhh"),
782            "plaintext secret leaked into pack-config-input: {body}"
783        );
784    }
785
786    /// Same answers + same bundle_id + same provider_id, different env_id →
787    /// different secret_refs. Pins the env-segment integrity of the URI.
788    #[test]
789    fn emit_pack_config_input_secret_refs_discriminate_on_env_id() {
790        let tmp_a = tempfile::TempDir::new().expect("tempdir-a");
791        let tmp_b = tempfile::TempDir::new().expect("tempdir-b");
792        let form = make_form_spec(vec![question("api_token", true)]);
793        let cfg = json!({"api_token": "x"});
794        let pa = emit_pack_config_input(tmp_a.path(), "local", "b", "p", &cfg, &form)
795            .expect("emit-a")
796            .expect("path-a");
797        let pb = emit_pack_config_input(tmp_b.path(), "staging", "b", "p", &cfg, &form)
798            .expect("emit-b")
799            .expect("path-b");
800        let parsed_a: PackConfigInput =
801            serde_json::from_slice(&std::fs::read(&pa).unwrap()).unwrap();
802        let parsed_b: PackConfigInput =
803            serde_json::from_slice(&std::fs::read(&pb).unwrap()).unwrap();
804        assert_eq!(
805            parsed_a.secret_refs.get("api_token").map(String::as_str),
806            Some("secret://local/b/p/api_token")
807        );
808        assert_eq!(
809            parsed_b.secret_refs.get("api_token").map(String::as_str),
810            Some("secret://staging/b/p/api_token")
811        );
812    }
813
814    /// Empty config → no file written (caller treats `Ok(None)` as no-op,
815    /// not as a soft error). Matches the existing `persist_qa_secrets`
816    /// short-circuit semantics.
817    #[test]
818    fn emit_pack_config_input_skips_empty_config() {
819        let tmp = tempfile::TempDir::new().expect("tempdir");
820        let root = tmp.path();
821        let form = make_form_spec(vec![question("enabled", false)]);
822        let empty = json!({});
823        assert!(
824            emit_pack_config_input(root, "local", "b", "p", &empty, &form)
825                .expect("emit")
826                .is_none()
827        );
828        assert!(!root.join(PACK_CONFIG_INPUT_DIR).exists());
829    }
830
831    /// Reject `/`-bearing or empty path segments — `secret://` URI integrity
832    /// + on-disk `<dir>/<pack_id>.json` layout depend on it.
833    #[test]
834    fn emit_pack_config_input_rejects_invalid_segments() {
835        let tmp = tempfile::TempDir::new().expect("tempdir");
836        let root = tmp.path();
837        let form = make_form_spec(vec![question("k", false)]);
838        let cfg = json!({"k": "v"});
839        assert!(
840            emit_pack_config_input(root, "", "b", "p", &cfg, &form).is_err(),
841            "empty env_id rejected"
842        );
843        assert!(
844            emit_pack_config_input(root, "local", "b", "../p", &cfg, &form).is_err(),
845            "pack_id with `/` rejected"
846        );
847        assert!(
848            emit_pack_config_input(root, "local", "b/c", "p", &cfg, &form).is_err(),
849            "bundle_id with `/` rejected"
850        );
851        assert!(
852            emit_pack_config_input(root, "local", "b", "..", &cfg, &form).is_err(),
853            "pack_id `..` rejected"
854        );
855        assert!(
856            emit_pack_config_input(root, ".", "b", "p", &cfg, &form).is_err(),
857            "env_id `.` rejected"
858        );
859    }
860
861    /// Invisible questions (conditional `visible_if` that evaluates to false)
862    /// must not leak into the pack-config-input file.
863    #[test]
864    fn emit_pack_config_input_respects_visibility() {
865        let tmp = tempfile::TempDir::new().expect("tempdir");
866        let root = tmp.path();
867        let form = make_form_spec(vec![question("mode", false), {
868            let mut q = question("advanced_url", false);
869            q.visible_if = Some(qa_spec::Expr::Eq {
870                left: Box::new(qa_spec::Expr::Answer {
871                    path: "mode".into(),
872                }),
873                right: Box::new(qa_spec::Expr::Literal {
874                    value: Value::String("advanced".into()),
875                }),
876            });
877            q
878        }]);
879        // mode=basic → advanced_url should be invisible
880        let config = json!({
881            "mode": "basic",
882            "advanced_url": "https://should-be-hidden.example.com",
883        });
884        let path = emit_pack_config_input(root, "local", "b", "p", &config, &form)
885            .expect("emit")
886            .expect("path");
887        let parsed: PackConfigInput =
888            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
889        assert!(
890            !parsed.non_secret.contains_key("advanced_url"),
891            "invisible question should not appear in non_secret: {parsed:?}"
892        );
893        assert_eq!(
894            parsed.non_secret.get("mode"),
895            Some(&Value::String("basic".into())),
896        );
897    }
898}