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