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