Skip to main content

greentic_deployer/cli/
bundles.rs

1//! `gtc op bundles {add,update,remove,list}` (`A3`).
2//!
3//! Manages `Environment.bundles: Vec<BundleDeployment>`. Each call records
4//! the bundle deployment metadata only — actual staging of a `.gtbundle`
5//! into a `Revision` happens via `op revisions stage`. The intentional
6//! split: `bundles` owns rollout-unit metadata (`route_binding`,
7//! `revenue_share`, `customer_id`); `revisions` owns the per-version
8//! artifact pointers.
9
10use std::collections::BTreeMap;
11use std::path::PathBuf;
12
13use greentic_deploy_spec::{
14    BundleDeployment, BundleDeploymentStatus, BundleId, CustomerId, DeploymentId, EnvId,
15    RevenueShareEntry, RouteBinding, TenantSelector,
16};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20use crate::environment::mutations::UpdateBundlePayload as StoreUpdateBundlePayload;
21use crate::environment::{
22    AddBundlePayload as StoreAddBundlePayload, EnvironmentReads, LocalFsStore, RemoveBundleOutcome,
23};
24
25use super::{
26    AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
27    resolve_idempotency_key,
28};
29
30const NOUN: &str = "bundles";
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct BundleAddPayload {
34    pub environment_id: String,
35    pub bundle_id: String,
36    /// Billing principal (P6). Required for non-`local` envs; defaults to
37    /// [`LOCAL_DEV_CUSTOMER_ID`] when omitted on the `local` env.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub customer_id: Option<String>,
40    pub route_binding: RouteBindingPayload,
41    #[serde(default = "default_revenue_share")]
42    pub revenue_share: Vec<RevenueShareEntryPayload>,
43    #[serde(default = "default_authorization_ref")]
44    pub authorization_ref: PathBuf,
45    /// D.4: per-pack provider config overrides applied at egress time.
46    /// Outer key = `pack_id`, inner key = config key, value = JSON value.
47    /// Structurally validated by `BundleDeployment::validate`; pack ids are
48    /// cross-referenced against the deployment's revisions in
49    /// `Environment::validate`.
50    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
51    pub config_overrides: BTreeMap<String, BTreeMap<String, Value>>,
52    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
53    /// surface; when absent, `add` mints one per invocation. Operators
54    /// wanting safe lost-response retries (HTTP backend, PR-3b) supply a
55    /// stable key.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub idempotency_key: Option<String>,
58}
59
60/// Default `customer_id` for the `local` env when none is supplied. Non-local
61/// envs must pass one explicitly (B10).
62const LOCAL_DEV_CUSTOMER_ID: &str = "local-dev";
63
64pub(crate) fn default_revenue_share() -> Vec<RevenueShareEntryPayload> {
65    vec![RevenueShareEntryPayload {
66        party_id: "greentic".to_string(),
67        basis_points: 10_000,
68    }]
69}
70pub(crate) fn default_authorization_ref() -> PathBuf {
71    PathBuf::from("auth.json")
72}
73
74/// Convert the CLI `RevenueShareEntryPayload` list into the spec
75/// [`RevenueShareEntry`] list. Shared by the remote dispatch's `bundles
76/// add`/`update` so the two HTTP call sites don't re-roll the mapping.
77pub(crate) fn convert_revenue_share(
78    entries: &[RevenueShareEntryPayload],
79) -> Vec<RevenueShareEntry> {
80    entries
81        .iter()
82        .cloned()
83        .map(|e| RevenueShareEntry {
84            party_id: greentic_deploy_spec::PartyId::new(e.party_id),
85            basis_points: e.basis_points,
86        })
87        .collect()
88}
89
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct RouteBindingPayload {
92    #[serde(default)]
93    pub hosts: Vec<String>,
94    #[serde(default)]
95    pub path_prefixes: Vec<String>,
96    #[serde(default)]
97    pub tenant_selector: Option<TenantSelectorPayload>,
98}
99
100impl RouteBindingPayload {
101    /// A binding with `tenant_selector` set but no host/path matcher is
102    /// structurally unreachable (no inbound request can match it). Reject at
103    /// payload-construction time so the CLI flag path and the `--answers`
104    /// JSON path share the same validation.
105    pub fn validate(&self) -> Result<(), OpError> {
106        if self.tenant_selector.is_some() && self.hosts.is_empty() && self.path_prefixes.is_empty()
107        {
108            return Err(OpError::InvalidArgument(
109                "route_binding: tenant_selector requires at least one host or path_prefix \
110                 (a binding with no matchers would be unreachable)"
111                    .to_string(),
112            ));
113        }
114        Ok(())
115    }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct TenantSelectorPayload {
120    pub tenant: String,
121    pub team: String,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct RevenueShareEntryPayload {
126    pub party_id: String,
127    pub basis_points: u32,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct BundleSummary {
132    pub environment_id: String,
133    pub bundle_id: String,
134    pub deployment_id: String,
135    pub customer_id: String,
136    pub status: BundleDeploymentStatus,
137    pub current_revision_count: usize,
138    pub hosts: Vec<String>,
139}
140
141impl BundleSummary {
142    pub(crate) fn from(env_id: &EnvId, b: &BundleDeployment) -> Self {
143        Self {
144            environment_id: env_id.as_str().to_string(),
145            bundle_id: b.bundle_id.as_str().to_string(),
146            deployment_id: b.deployment_id.to_string(),
147            customer_id: b.customer_id.as_str().to_string(),
148            status: b.status,
149            current_revision_count: b.current_revisions.len(),
150            hosts: b.route_binding.hosts.clone(),
151        }
152    }
153}
154
155pub fn add(
156    store: &LocalFsStore,
157    flags: &OpFlags,
158    payload: Option<BundleAddPayload>,
159) -> Result<OpOutcome, OpError> {
160    if flags.schema_only {
161        return Ok(OpOutcome::new(NOUN, "add", add_schema()));
162    }
163    let payload = resolve_payload(flags, payload)?;
164    let env_id = parse_env_id(&payload.environment_id)?;
165    if payload.bundle_id.trim().is_empty() {
166        return Err(OpError::InvalidArgument(
167            "bundle_id must not be empty".to_string(),
168        ));
169    }
170    let bundle_id = BundleId::new(payload.bundle_id);
171    // P6 (B10): customer_id is the billing principal. Required for non-local
172    // envs; defaults to `local-dev` on `local`. Validated before authz so a
173    // missing principal surfaces as a precise argument error.
174    let customer_id = resolve_customer_id(&env_id, payload.customer_id.clone())?;
175    let revenue_share: Vec<RevenueShareEntry> = payload
176        .revenue_share
177        .iter()
178        .cloned()
179        .map(|e| RevenueShareEntry {
180            party_id: greentic_deploy_spec::PartyId::new(e.party_id),
181            basis_points: e.basis_points,
182        })
183        .collect();
184    let config_overrides = payload.config_overrides.clone();
185    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
186    let route_binding_payload = payload.route_binding.clone();
187    let ctx = AuditCtx {
188        env_id: env_id.clone(),
189        noun: NOUN,
190        verb: "add",
191        target: json!({
192            "bundle_id": bundle_id.as_str(),
193            "customer_id": customer_id.as_str(),
194            "revenue_share": revenue_share_json(&revenue_share),
195            "config_overrides": config_overrides_audit_shape(&config_overrides),
196        }),
197        idempotency_key: Some(idempotency_key.as_str().to_string()),
198    };
199    audit_and_record(store, ctx, |_committed| {
200        let deployment = store
201            .add_bundle(
202                &env_id,
203                StoreAddBundlePayload {
204                    bundle_id,
205                    customer_id,
206                    revenue_share,
207                    route_binding: Some(into_route_binding(route_binding_payload)),
208                    authorization_ref: Some(
209                        payload.authorization_ref.to_string_lossy().into_owned(),
210                    ),
211                    config_overrides,
212                },
213                idempotency_key,
214            )
215            .map_err(map_store_err_preserving_noun)?;
216        let summary = BundleSummary::from(&env_id, &deployment);
217        let outcome = OpOutcome::new(
218            NOUN,
219            "add",
220            serde_json::to_value(summary).expect("BundleSummary is json-safe"),
221        );
222        Ok((outcome, super::AuditGens::NONE))
223    })
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct BundleUpdatePayload {
228    pub environment_id: String,
229    pub deployment_id: String,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub status: Option<BundleDeploymentStatus>,
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub route_binding: Option<RouteBindingPayload>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub revenue_share: Option<Vec<RevenueShareEntryPayload>>,
236    /// D.4: replace the deployment's config_overrides map.
237    ///
238    /// `None` (the default) leaves the existing overrides untouched.
239    /// `Some(map)` replaces them wholesale — pass `Some(empty)` to clear.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
242    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
243    /// surface; when absent, `update` mints one per invocation. Operators
244    /// wanting safe lost-response retries (HTTP backend, PR-3b) supply a
245    /// stable key.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub idempotency_key: Option<String>,
248}
249
250pub fn update(
251    store: &LocalFsStore,
252    flags: &OpFlags,
253    payload: Option<BundleUpdatePayload>,
254) -> Result<OpOutcome, OpError> {
255    if flags.schema_only {
256        return Ok(OpOutcome::new(NOUN, "update", update_schema()));
257    }
258    let payload = resolve_payload::<BundleUpdatePayload>(flags, payload)?;
259    let env_id = parse_env_id(&payload.environment_id)?;
260    let deployment_id = parse_deployment_id(&payload.deployment_id)?;
261    // Parse the revenue-share change up front so the audit event records it
262    // (the plan requires `bundle update` audit to carry revenue_share changes).
263    let new_revenue_share: Option<Vec<RevenueShareEntry>> =
264        payload.revenue_share.as_ref().map(|s| {
265            s.iter()
266                .cloned()
267                .map(|e| RevenueShareEntry {
268                    party_id: greentic_deploy_spec::PartyId::new(e.party_id),
269                    basis_points: e.basis_points,
270                })
271                .collect()
272        });
273    let new_route_binding = payload.route_binding.clone().map(into_route_binding);
274    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
275    let mut target = json!({"deployment_id": deployment_id.to_string()});
276    if let Some(shares) = &new_revenue_share {
277        target["revenue_share"] = revenue_share_json(shares);
278    }
279    if let Some(overrides) = &payload.config_overrides {
280        target["config_overrides"] = config_overrides_audit_shape(overrides);
281    }
282    let ctx = AuditCtx {
283        env_id: env_id.clone(),
284        noun: NOUN,
285        verb: "update",
286        target,
287        idempotency_key: Some(idempotency_key.as_str().to_string()),
288    };
289    audit_and_record(store, ctx, |_committed| {
290        let deployment = store
291            .update_bundle(
292                &env_id,
293                StoreUpdateBundlePayload {
294                    deployment_id,
295                    status: payload.status,
296                    route_binding: new_route_binding,
297                    revenue_share: new_revenue_share,
298                    config_overrides: payload.config_overrides,
299                },
300                idempotency_key,
301            )
302            .map_err(map_store_err_preserving_noun)?;
303        let summary = BundleSummary::from(&env_id, &deployment);
304        let outcome = OpOutcome::new(
305            NOUN,
306            "update",
307            serde_json::to_value(summary).expect("BundleSummary is json-safe"),
308        );
309        Ok((outcome, super::AuditGens::NONE))
310    })
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct BundleRemovePayload {
315    pub environment_id: String,
316    pub deployment_id: String,
317    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
318    /// surface; when absent, `remove` mints one per invocation. Operators
319    /// wanting safe lost-response retries (HTTP backend, PR-3b) supply a
320    /// stable key.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub idempotency_key: Option<String>,
323}
324
325pub fn remove(
326    store: &LocalFsStore,
327    flags: &OpFlags,
328    payload: Option<BundleRemovePayload>,
329) -> Result<OpOutcome, OpError> {
330    if flags.schema_only {
331        return Ok(OpOutcome::new(NOUN, "remove", remove_schema()));
332    }
333    let payload = resolve_payload::<BundleRemovePayload>(flags, payload)?;
334    let env_id = parse_env_id(&payload.environment_id)?;
335    let deployment_id = parse_deployment_id(&payload.deployment_id)?;
336    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
337    let ctx = AuditCtx {
338        env_id: env_id.clone(),
339        noun: NOUN,
340        verb: "remove",
341        // `pruned_revision_ids` is added inside the closure once the typed
342        // verb returns the prune set — the destructive side effect is
343        // explicit in the audit event.
344        target: json!({"deployment_id": deployment_id.to_string()}),
345        idempotency_key: Some(idempotency_key.as_str().to_string()),
346    };
347    audit_and_record(store, ctx, |_committed| {
348        let RemoveBundleOutcome {
349            deployment,
350            pruned_revision_ids,
351        } = store
352            .remove_bundle(&env_id, deployment_id, idempotency_key)
353            .map_err(map_store_err_preserving_noun)?;
354        let summary = BundleSummary::from(&env_id, &deployment);
355        let mut result = serde_json::to_value(summary).expect("BundleSummary is json-safe");
356        // Surface pruned IDs on the wire response so HTTP callers see
357        // exactly what was destroyed (matches what the audit event now
358        // captures via the explicit-side-effect contract).
359        result["pruned_revision_ids"] = json!(
360            pruned_revision_ids
361                .iter()
362                .map(|r| r.to_string())
363                .collect::<Vec<_>>()
364        );
365        let outcome = OpOutcome::new(NOUN, "remove", result);
366        Ok((outcome, super::AuditGens::NONE))
367    })
368}
369
370pub fn list(
371    store: &dyn EnvironmentReads,
372    flags: &OpFlags,
373    env_id: &str,
374) -> Result<OpOutcome, OpError> {
375    if flags.schema_only {
376        return Ok(OpOutcome::new(
377            NOUN,
378            "list",
379            json!({"input_schema": "env_id positional"}),
380        ));
381    }
382    let env_id = parse_env_id(env_id)?;
383    if !store.env_exists(&env_id)? {
384        return Err(OpError::NotFound(format!("environment `{env_id}`")));
385    }
386    let env = store.load_env(&env_id)?;
387    let deployments: Vec<BundleSummary> = env
388        .bundles
389        .iter()
390        .map(|b| BundleSummary::from(&env_id, b))
391        .collect();
392    Ok(OpOutcome::new(
393        NOUN,
394        "list",
395        json!({"environment_id": env_id.as_str(), "deployments": deployments}),
396    ))
397}
398
399// --- internals -----------------------------------------------------------
400
401fn resolve_payload<T: serde::de::DeserializeOwned>(
402    flags: &OpFlags,
403    payload: Option<T>,
404) -> Result<T, OpError> {
405    if let Some(p) = payload {
406        return Ok(p);
407    }
408    if let Some(path) = &flags.answers {
409        return super::load_answers::<T>(path);
410    }
411    Err(OpError::InvalidArgument(
412        "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
413    ))
414}
415
416fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
417    EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
418}
419
420/// P6 (B10): resolve the billing principal. `local` defaults to `local-dev`
421/// when none is supplied; every other env must pass one explicitly.
422pub(crate) fn resolve_customer_id(
423    env_id: &EnvId,
424    supplied: Option<String>,
425) -> Result<CustomerId, OpError> {
426    match supplied {
427        Some(c) if c.trim().is_empty() => Err(OpError::InvalidArgument(
428            "customer_id must not be empty".to_string(),
429        )),
430        Some(c) => Ok(CustomerId::new(c)),
431        None if env_id.as_str() == crate::defaults::LOCAL_ENV_ID => {
432            Ok(CustomerId::new(LOCAL_DEV_CUSTOMER_ID))
433        }
434        None => Err(OpError::InvalidArgument(format!(
435            "customer_id is required for non-local env `{env_id}` (the billing principal; P6)"
436        ))),
437    }
438}
439
440/// Render `config_overrides` as a key-shape only payload for audit logs:
441/// `{"<pack_id>": ["<key>", ...], ...}`. Values are NEVER logged — they
442/// may carry secrets-adjacent material (e.g. an API base URL with a
443/// query-string token), and the audit trail must not be a leak channel.
444///
445/// TODO(d4-followup): config-key schema linter — see Codex review thread on
446/// PR #243. Override key NAMES are user-controlled; a future PR should
447/// validate against a known-safe set OR log counts/hashes by default so a
448/// token/URI accidentally placed in a key name cannot leak through audit.
449pub(super) fn config_overrides_audit_shape(
450    overrides: &BTreeMap<String, BTreeMap<String, Value>>,
451) -> Value {
452    let mut shape = serde_json::Map::with_capacity(overrides.len());
453    for (pack_id, keys) in overrides {
454        let keys: Vec<Value> = keys.keys().map(|k| Value::String(k.clone())).collect();
455        shape.insert(pack_id.clone(), Value::Array(keys));
456    }
457    Value::Object(shape)
458}
459
460/// Render revenue-share entries for the audit `target` payload.
461fn revenue_share_json(shares: &[RevenueShareEntry]) -> Value {
462    Value::Array(
463        shares
464            .iter()
465            .map(|e| json!({"party_id": e.party_id.as_str(), "basis_points": e.basis_points}))
466            .collect(),
467    )
468}
469
470fn parse_deployment_id(raw: &str) -> Result<DeploymentId, OpError> {
471    use std::str::FromStr;
472    let ulid = ulid::Ulid::from_str(raw)
473        .map_err(|e| OpError::InvalidArgument(format!("deployment_id: {e}")))?;
474    Ok(DeploymentId(ulid))
475}
476
477pub(crate) fn into_route_binding(payload: RouteBindingPayload) -> RouteBinding {
478    let tenant_selector = payload
479        .tenant_selector
480        .map(|t| TenantSelector {
481            tenant: t.tenant,
482            team: t.team,
483        })
484        .unwrap_or_else(|| TenantSelector {
485            tenant: "default".to_string(),
486            team: "default".to_string(),
487        });
488    RouteBinding {
489        hosts: payload.hosts,
490        path_prefixes: payload.path_prefixes,
491        tenant_selector,
492    }
493}
494
495fn add_schema() -> Value {
496    json!({
497        "$schema": "https://json-schema.org/draft/2020-12/schema",
498        "title": "BundleAddPayload",
499        "type": "object",
500        "required": ["environment_id", "bundle_id", "route_binding"],
501        "additionalProperties": false,
502        "properties": {
503            "environment_id": {"type": "string"},
504            "bundle_id": {"type": "string"},
505            "customer_id": {"type": "string", "description": "billing principal; required for non-local envs, defaults to `local-dev` on `local`"},
506            "route_binding": {"type": "object"},
507            "revenue_share": {"type": "array"},
508            "authorization_ref": {"type": "string"},
509            "config_overrides": {
510                "type": "object",
511                "description": "D.4: per-pack provider config overrides — object keyed by pack_id, values are objects of {key: json-value}",
512                "additionalProperties": {"type": "object"}
513            },
514            "idempotency_key": {
515                "type": "string",
516                "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
517            }
518        }
519    })
520}
521
522fn update_schema() -> Value {
523    json!({
524        "$schema": "https://json-schema.org/draft/2020-12/schema",
525        "title": "BundleUpdatePayload",
526        "type": "object",
527        "required": ["environment_id", "deployment_id"],
528        "additionalProperties": false,
529        "properties": {
530            "environment_id": {"type": "string"},
531            "deployment_id": {"type": "string", "description": "ULID"},
532            "status": {"type": "string", "enum": ["active", "paused", "archived"]},
533            "route_binding": {"type": "object"},
534            "revenue_share": {"type": "array"},
535            "config_overrides": {
536                "type": "object",
537                "description": "D.4: replace the deployment's config_overrides map wholesale (omit to leave untouched; pass `{}` to clear)",
538                "additionalProperties": {"type": "object"}
539            },
540            "idempotency_key": {
541                "type": "string",
542                "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
543            }
544        }
545    })
546}
547
548fn remove_schema() -> Value {
549    json!({
550        "$schema": "https://json-schema.org/draft/2020-12/schema",
551        "title": "BundleRemovePayload",
552        "type": "object",
553        "required": ["environment_id", "deployment_id"],
554        "additionalProperties": false,
555        "properties": {
556            "environment_id": {"type": "string"},
557            "deployment_id": {"type": "string", "description": "ULID"},
558            "idempotency_key": {
559                "type": "string",
560                "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
561            }
562        }
563    })
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use crate::cli::tests_common::make_env;
570    use crate::environment::EnvironmentStore;
571    use tempfile::tempdir;
572
573    /// PR-3a.7 Codex regression: `BundleRemovePayload` accepts an
574    /// `idempotency_key` field; the schema published via `--schema` MUST
575    /// list it under `properties`, otherwise schema-driven callers
576    /// (`gtc op bundles remove --schema | jsonschema-validator …`)
577    /// reject the exact field needed for stable A8 §2 retry keys.
578    #[test]
579    fn remove_schema_lists_idempotency_key() {
580        let schema = remove_schema();
581        assert!(
582            schema.pointer("/properties/idempotency_key").is_some(),
583            "remove_schema must list `idempotency_key` so --schema-driven \
584             callers can supply the A8 retry key (schema: {schema:#})"
585        );
586    }
587
588    /// PR-3a.7b: `BundleAddPayload` accepts an `idempotency_key` field;
589    /// the schema published via `--schema` MUST list it under `properties`,
590    /// otherwise schema-driven callers reject the field.
591    #[test]
592    fn add_schema_lists_idempotency_key() {
593        let schema = add_schema();
594        assert!(
595            schema.pointer("/properties/idempotency_key").is_some(),
596            "add_schema must list `idempotency_key` so --schema-driven \
597             callers can supply the A8 retry key (schema: {schema:#})"
598        );
599    }
600
601    /// PR-3a.7b: `BundleUpdatePayload` accepts an `idempotency_key` field;
602    /// the schema published via `--schema` MUST list it under `properties`,
603    /// otherwise schema-driven callers reject the field.
604    #[test]
605    fn update_schema_lists_idempotency_key() {
606        let schema = update_schema();
607        assert!(
608            schema.pointer("/properties/idempotency_key").is_some(),
609            "update_schema must list `idempotency_key` so --schema-driven \
610             callers can supply the A8 retry key (schema: {schema:#})"
611        );
612    }
613
614    /// PR-3a.7 Codex regression: removing a bundle prunes its archived
615    /// revisions, but the destructive side effect must be explicit on the
616    /// wire response (and the audit event) — HTTP backends will apply a
617    /// separate authz check against the prune set.
618    #[test]
619    fn remove_response_surfaces_pruned_revision_ids() {
620        use greentic_deploy_spec::RevisionLifecycle;
621        let dir = tempdir().unwrap();
622        let store = LocalFsStore::new(dir.path());
623        let env_id = EnvId::try_from("local").unwrap();
624        store.save(&make_env("local")).unwrap();
625        let env_dir = store.env_dir(&env_id).unwrap();
626        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
627
628        let added = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
629        let did_str = added
630            .result
631            .get("deployment_id")
632            .and_then(|v| v.as_str())
633            .unwrap()
634            .to_string();
635        let deployment_id = parse_deployment_id(&did_str).unwrap();
636
637        // Seed two archived revisions under that deployment (the live-
638        // state guard demands archived; otherwise remove refuses).
639        let mut env = store.load(&env_id).unwrap();
640        for _ in 0..2 {
641            env.revisions.push(crate::cli::tests_common::make_revision(
642                "local",
643                "fast2flow",
644                &deployment_id,
645                1,
646                RevisionLifecycle::Archived,
647            ));
648        }
649        store.save(&env).unwrap();
650
651        let removed = remove(
652            &store,
653            &OpFlags::default(),
654            Some(BundleRemovePayload {
655                environment_id: "local".to_string(),
656                deployment_id: did_str,
657                idempotency_key: None,
658            }),
659        )
660        .unwrap();
661
662        let pruned = removed
663            .result
664            .get("pruned_revision_ids")
665            .and_then(|v| v.as_array())
666            .expect("pruned_revision_ids on response");
667        assert_eq!(
668            pruned.len(),
669            2,
670            "both archived revisions surface in the response: {removed:#?}"
671        );
672    }
673
674    fn payload(bundle_id: &str) -> BundleAddPayload {
675        BundleAddPayload {
676            environment_id: "local".to_string(),
677            bundle_id: bundle_id.to_string(),
678            customer_id: Some("local-dev".to_string()),
679            route_binding: RouteBindingPayload {
680                hosts: vec![format!("{bundle_id}.local")],
681                path_prefixes: Vec::new(),
682                tenant_selector: None,
683            },
684            revenue_share: default_revenue_share(),
685            authorization_ref: default_authorization_ref(),
686            config_overrides: BTreeMap::new(),
687            idempotency_key: None,
688        }
689    }
690
691    #[test]
692    fn add_then_list_returns_deployment() {
693        let dir = tempdir().unwrap();
694        let store = LocalFsStore::new(dir.path());
695        store.save(&make_env("local")).unwrap();
696        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
697        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
698        let outcome = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
699        let did = outcome
700            .result
701            .get("deployment_id")
702            .and_then(|v| v.as_str())
703            .unwrap();
704        assert!(!did.is_empty());
705        let listed = list(&store, &OpFlags::default(), "local").unwrap();
706        let deployments = listed
707            .result
708            .get("deployments")
709            .and_then(|v| v.as_array())
710            .expect("deployments array");
711        assert_eq!(deployments.len(), 1);
712    }
713
714    #[test]
715    fn add_without_bootstrap_surfaces_operator_key_not_trusted() {
716        // xhigh #10: `bundles add` must NOT auto-generate `~/.greentic/operator/key.pem`
717        // as a side effect of a command that then fails the trust-root
718        // precondition. With `load_existing_only`, an unbootstrapped env
719        // surfaces a clean OperatorKeyNotTrusted (or NotFound when the
720        // operator key itself doesn't exist yet).
721        let dir = tempdir().unwrap();
722        let store = LocalFsStore::new(dir.path());
723        store.save(&make_env("local")).unwrap();
724        // NOTE: NO bootstrap_env_trust_root call here. The fixture would
725        // have side-effected `~/.greentic/operator/key.pem` and added it
726        // to the env trust root; this test asserts that absent both, the
727        // CLI refuses without auto-creating either.
728        let err = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap_err();
729        // Either OperatorKey (key doesn't exist) or RevenuePolicy
730        // (key exists from a prior run but isn't in this env's trust root)
731        // is acceptable — both signal "operator must bootstrap first".
732        assert!(
733            matches!(
734                err,
735                OpError::OperatorKey(_)
736                    | OpError::RevenuePolicy(
737                        crate::environment::BundleDeploymentError::OperatorKeyNotTrusted { .. }
738                    )
739            ),
740            "expected OperatorKey or OperatorKeyNotTrusted, got {err:?}"
741        );
742    }
743
744    #[test]
745    fn add_rejects_duplicate_bundle_customer() {
746        let dir = tempdir().unwrap();
747        let store = LocalFsStore::new(dir.path());
748        store.save(&make_env("local")).unwrap();
749        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
750        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
751        add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
752        let err = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap_err();
753        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
754    }
755
756    #[test]
757    fn add_allows_same_bundle_for_different_customer() {
758        let dir = tempdir().unwrap();
759        let store = LocalFsStore::new(dir.path());
760        store.save(&make_env("local")).unwrap();
761        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
762        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
763        add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
764        let mut p2 = payload("fast2flow");
765        p2.customer_id = Some("other".to_string());
766        let outcome = add(&store, &OpFlags::default(), Some(p2)).unwrap();
767        assert_eq!(outcome.op, "add");
768    }
769
770    #[test]
771    fn update_changes_status_and_route_binding() {
772        let dir = tempdir().unwrap();
773        let store = LocalFsStore::new(dir.path());
774        store.save(&make_env("local")).unwrap();
775        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
776        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
777        let added = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
778        let did = added
779            .result
780            .get("deployment_id")
781            .and_then(|v| v.as_str())
782            .unwrap()
783            .to_string();
784        let outcome = update(
785            &store,
786            &OpFlags::default(),
787            Some(BundleUpdatePayload {
788                environment_id: "local".to_string(),
789                deployment_id: did.clone(),
790                status: Some(BundleDeploymentStatus::Paused),
791                route_binding: Some(RouteBindingPayload {
792                    hosts: vec!["new.example.com".to_string()],
793                    path_prefixes: vec!["/v1".to_string()],
794                    tenant_selector: None,
795                }),
796                revenue_share: None,
797                config_overrides: None,
798                idempotency_key: None,
799            }),
800        )
801        .unwrap();
802        assert_eq!(
803            outcome.result.get("status").and_then(|v| v.as_str()),
804            Some("paused")
805        );
806        let hosts = outcome
807            .result
808            .get("hosts")
809            .and_then(|v| v.as_array())
810            .unwrap();
811        assert_eq!(hosts.len(), 1);
812        assert_eq!(hosts[0].as_str(), Some("new.example.com"));
813    }
814
815    #[test]
816    fn remove_rejects_deployment_with_revisions() {
817        let dir = tempdir().unwrap();
818        let store = LocalFsStore::new(dir.path());
819        let mut env = make_env("local");
820        let mut bundle = crate::cli::tests_common::make_bundle_deployment("local", "fast2flow");
821        let did = bundle.deployment_id;
822        // `Environment::validate` requires every `current_revisions` id to
823        // resolve to a real Revision in the env, so push a matching one.
824        let revision = crate::cli::tests_common::make_revision(
825            "local",
826            "fast2flow",
827            &did,
828            1,
829            greentic_deploy_spec::RevisionLifecycle::Ready,
830        );
831        bundle.current_revisions.push(revision.revision_id);
832        env.bundles.push(bundle);
833        env.revisions.push(revision);
834        store.save(&env).unwrap();
835        let err = remove(
836            &store,
837            &OpFlags::default(),
838            Some(BundleRemovePayload {
839                environment_id: "local".to_string(),
840                deployment_id: did.to_string(),
841                idempotency_key: None,
842            }),
843        )
844        .unwrap_err();
845        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
846    }
847
848    #[test]
849    fn remove_with_no_revisions_succeeds() {
850        let dir = tempdir().unwrap();
851        let store = LocalFsStore::new(dir.path());
852        store.save(&make_env("local")).unwrap();
853        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
854        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
855        let added = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
856        let did = added
857            .result
858            .get("deployment_id")
859            .and_then(|v| v.as_str())
860            .unwrap()
861            .to_string();
862        remove(
863            &store,
864            &OpFlags::default(),
865            Some(BundleRemovePayload {
866                environment_id: "local".to_string(),
867                deployment_id: did,
868                idempotency_key: None,
869            }),
870        )
871        .unwrap();
872        let listed = list(&store, &OpFlags::default(), "local").unwrap();
873        let deployments = listed.result.get("deployments").and_then(|v| v.as_array());
874        assert!(deployments.map(|v| v.is_empty()).unwrap_or(false));
875    }
876
877    #[test]
878    fn remove_rejects_when_traffic_split_references_deployment() {
879        // Codex regression: the prior guard checked `current_revisions`,
880        // which the CLI stage/warm/traffic path never populates. Verify
881        // that a live traffic split blocks removal even when
882        // current_revisions is empty.
883        let dir = tempdir().unwrap();
884        let store = LocalFsStore::new(dir.path());
885        let mut env = make_env("local");
886        let bundle = crate::cli::tests_common::make_bundle_deployment("local", "fast2flow");
887        let did = bundle.deployment_id;
888        // Note: current_revisions intentionally left empty — matches the
889        // CLI path's state.
890        let revision = crate::cli::tests_common::make_revision(
891            "local",
892            "fast2flow",
893            &did,
894            1,
895            greentic_deploy_spec::RevisionLifecycle::Ready,
896        );
897        let split = crate::cli::tests_common::make_traffic_split(
898            "local",
899            "fast2flow",
900            &did,
901            &revision.revision_id,
902            "k1",
903        );
904        env.bundles.push(bundle);
905        env.revisions.push(revision);
906        env.traffic_splits.push(split);
907        store.save(&env).unwrap();
908        let err = remove(
909            &store,
910            &OpFlags::default(),
911            Some(BundleRemovePayload {
912                environment_id: "local".to_string(),
913                deployment_id: did.to_string(),
914                idempotency_key: None,
915            }),
916        )
917        .unwrap_err();
918        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
919    }
920
921    #[test]
922    fn remove_rejects_when_non_archived_revision_exists() {
923        // Same C4 finding, the other live-state signal: a non-archived
924        // revision (even one that's not yet referenced by a traffic split)
925        // must block removal.
926        let dir = tempdir().unwrap();
927        let store = LocalFsStore::new(dir.path());
928        let mut env = make_env("local");
929        let bundle = crate::cli::tests_common::make_bundle_deployment("local", "fast2flow");
930        let did = bundle.deployment_id;
931        let revision = crate::cli::tests_common::make_revision(
932            "local",
933            "fast2flow",
934            &did,
935            1,
936            greentic_deploy_spec::RevisionLifecycle::Staged,
937        );
938        env.bundles.push(bundle);
939        env.revisions.push(revision);
940        store.save(&env).unwrap();
941        let err = remove(
942            &store,
943            &OpFlags::default(),
944            Some(BundleRemovePayload {
945                environment_id: "local".to_string(),
946                deployment_id: did.to_string(),
947                idempotency_key: None,
948            }),
949        )
950        .unwrap_err();
951        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
952    }
953
954    // --- B10: customer_id requirement + signed/versioned revenue policy ----
955
956    #[test]
957    fn add_writes_v1_revenue_policy_and_pins_ref() {
958        let dir = tempdir().unwrap();
959        let store = LocalFsStore::new(dir.path());
960        store.save(&make_env("local")).unwrap();
961        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
962        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
963        add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
964
965        let env = store.load(&parse_env_id("local").unwrap()).unwrap();
966        let dep = &env.bundles[0];
967        assert_eq!(
968            dep.revenue_policy_ref,
969            PathBuf::from("billing-policies/fast2flow/local-dev/v1.json.sig")
970        );
971        let env_dir = dir.path().join("local");
972        assert!(env_dir.join(&dep.revenue_policy_ref).is_file());
973        assert!(
974            env_dir
975                .join("billing-policies/fast2flow/local-dev/v1.json")
976                .is_file()
977        );
978    }
979
980    #[test]
981    fn add_overwrites_orphan_v1_from_failed_prior_attempt() {
982        // Codex regression: a prior `add` that wrote v1.json but failed before
983        // committing env.json must NOT cause the retry to advance to v2 and
984        // chain through a never-committed/dangling v1. Since the deployment
985        // isn't committed, the retry stays at v1 and overwrites the orphan.
986        let dir = tempdir().unwrap();
987        let store = LocalFsStore::new(dir.path());
988        store.save(&make_env("local")).unwrap();
989        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
990        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
991        // Simulate the orphan document left by a failed attempt.
992        let orphan_dir = dir
993            .path()
994            .join("local/billing-policies/fast2flow/local-dev");
995        std::fs::create_dir_all(&orphan_dir).unwrap();
996        std::fs::write(orphan_dir.join("v1.json"), b"{\"stale\":true}").unwrap();
997
998        add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
999
1000        let env = store.load(&parse_env_id("local").unwrap()).unwrap();
1001        assert_eq!(
1002            env.bundles[0].revenue_policy_ref,
1003            PathBuf::from("billing-policies/fast2flow/local-dev/v1.json.sig"),
1004            "retry must reuse v1, not advance past the orphan"
1005        );
1006        assert!(!orphan_dir.join("v2.json").exists());
1007        // The orphan was overwritten with a valid versioned document.
1008        let doc: greentic_deploy_spec::RevenuePolicyDocument =
1009            serde_json::from_slice(&std::fs::read(orphan_dir.join("v1.json")).unwrap()).unwrap();
1010        assert_eq!(doc.version, 1);
1011        assert!(doc.validate().is_ok());
1012    }
1013
1014    #[test]
1015    fn add_rejects_empty_bundle_id_early() {
1016        let dir = tempdir().unwrap();
1017        let store = LocalFsStore::new(dir.path());
1018        store.save(&make_env("local")).unwrap();
1019        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1020        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1021        let mut p = payload("fast2flow");
1022        p.bundle_id = "".to_string();
1023        let err = add(&store, &OpFlags::default(), Some(p)).unwrap_err();
1024        assert!(
1025            matches!(&err, OpError::InvalidArgument(m) if m.contains("bundle_id")),
1026            "got {err:?}"
1027        );
1028        // No partial billing-policy artifacts left behind.
1029        assert!(!dir.path().join("local/billing-policies").exists());
1030    }
1031
1032    #[test]
1033    fn add_rejects_empty_customer_id_early() {
1034        let dir = tempdir().unwrap();
1035        let store = LocalFsStore::new(dir.path());
1036        store.save(&make_env("local")).unwrap();
1037        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1038        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1039        let mut p = payload("fast2flow");
1040        p.customer_id = Some("".to_string());
1041        let err = add(&store, &OpFlags::default(), Some(p)).unwrap_err();
1042        assert!(
1043            matches!(&err, OpError::InvalidArgument(m) if m.contains("customer_id")),
1044            "got {err:?}"
1045        );
1046        assert!(!dir.path().join("local/billing-policies").exists());
1047    }
1048
1049    #[test]
1050    fn add_local_defaults_customer_id_when_omitted() {
1051        let dir = tempdir().unwrap();
1052        let store = LocalFsStore::new(dir.path());
1053        store.save(&make_env("local")).unwrap();
1054        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1055        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1056        let mut p = payload("fast2flow");
1057        p.customer_id = None;
1058        let outcome = add(&store, &OpFlags::default(), Some(p)).unwrap();
1059        assert_eq!(
1060            outcome.result.get("customer_id").and_then(|v| v.as_str()),
1061            Some("local-dev")
1062        );
1063    }
1064
1065    #[test]
1066    fn add_non_local_without_customer_id_is_invalid_argument() {
1067        let dir = tempdir().unwrap();
1068        let store = LocalFsStore::new(dir.path());
1069        store.save(&make_env("prod-eu")).unwrap();
1070        let mut p = payload("fast2flow");
1071        p.environment_id = "prod-eu".to_string();
1072        p.customer_id = None;
1073        // The argument contract is checked before authorization, so a missing
1074        // billing principal surfaces precisely (not as a generic authz deny).
1075        let err = add(&store, &OpFlags::default(), Some(p)).unwrap_err();
1076        assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1077    }
1078
1079    #[test]
1080    fn add_non_local_with_customer_id_is_not_authz_denied() {
1081        // Named envs are first-class on the local store: the `local`-only authz
1082        // denial is gone. With the billing principal supplied (customer_id gate
1083        // satisfied), `bundles add` on a non-local env is no longer rejected by
1084        // the authorization gate. (Other preconditions may still apply; this
1085        // pins only that the gate no longer blocks named envs.)
1086        let dir = tempdir().unwrap();
1087        let store = LocalFsStore::new(dir.path());
1088        store.save(&make_env("prod-eu")).unwrap();
1089        let mut p = payload("fast2flow");
1090        p.environment_id = "prod-eu".to_string();
1091        p.customer_id = Some("cust-acme".to_string());
1092        let result = add(&store, &OpFlags::default(), Some(p));
1093        assert!(
1094            !matches!(result, Err(OpError::Unauthorized { .. })),
1095            "named env must not be authz-denied; got {result:?}"
1096        );
1097    }
1098
1099    #[test]
1100    fn update_revenue_share_writes_new_version_and_chains() {
1101        let dir = tempdir().unwrap();
1102        let store = LocalFsStore::new(dir.path());
1103        store.save(&make_env("local")).unwrap();
1104        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1105        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1106        let added = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
1107        let did = added
1108            .result
1109            .get("deployment_id")
1110            .and_then(|v| v.as_str())
1111            .unwrap()
1112            .to_string();
1113
1114        update(
1115            &store,
1116            &OpFlags::default(),
1117            Some(BundleUpdatePayload {
1118                environment_id: "local".to_string(),
1119                deployment_id: did,
1120                status: None,
1121                route_binding: None,
1122                revenue_share: Some(vec![
1123                    RevenueShareEntryPayload {
1124                        party_id: "agency-a".to_string(),
1125                        basis_points: 3_000,
1126                    },
1127                    RevenueShareEntryPayload {
1128                        party_id: "greentic".to_string(),
1129                        basis_points: 7_000,
1130                    },
1131                ]),
1132                config_overrides: None,
1133                idempotency_key: None,
1134            }),
1135        )
1136        .unwrap();
1137
1138        let env = store.load(&parse_env_id("local").unwrap()).unwrap();
1139        let dep = &env.bundles[0];
1140        assert_eq!(
1141            dep.revenue_policy_ref,
1142            PathBuf::from("billing-policies/fast2flow/local-dev/v2.json.sig")
1143        );
1144        let env_dir = dir.path().join("local");
1145        assert!(
1146            env_dir
1147                .join("billing-policies/fast2flow/local-dev/v2.json")
1148                .is_file()
1149        );
1150        // Audit recorded the revenue-share change.
1151        let audit = std::fs::read_to_string(env_dir.join("audit/events.jsonl")).unwrap();
1152        assert!(
1153            audit.contains("agency-a") && audit.contains("3000"),
1154            "update audit event must carry the revenue_share change: {audit}"
1155        );
1156    }
1157
1158    #[test]
1159    fn update_without_revenue_share_keeps_policy_ref() {
1160        let dir = tempdir().unwrap();
1161        let store = LocalFsStore::new(dir.path());
1162        store.save(&make_env("local")).unwrap();
1163        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1164        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1165        let added = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
1166        let did = added
1167            .result
1168            .get("deployment_id")
1169            .and_then(|v| v.as_str())
1170            .unwrap()
1171            .to_string();
1172        update(
1173            &store,
1174            &OpFlags::default(),
1175            Some(BundleUpdatePayload {
1176                environment_id: "local".to_string(),
1177                deployment_id: did,
1178                status: Some(BundleDeploymentStatus::Paused),
1179                route_binding: None,
1180                revenue_share: None,
1181                config_overrides: None,
1182                idempotency_key: None,
1183            }),
1184        )
1185        .unwrap();
1186        let env = store.load(&parse_env_id("local").unwrap()).unwrap();
1187        assert_eq!(
1188            env.bundles[0].revenue_policy_ref,
1189            PathBuf::from("billing-policies/fast2flow/local-dev/v1.json.sig")
1190        );
1191    }
1192
1193    // ---- D.4 train-2 ----------------------------------------------------
1194
1195    fn payload_with_overrides(
1196        bundle_id: &str,
1197        overrides: BTreeMap<String, BTreeMap<String, Value>>,
1198    ) -> BundleAddPayload {
1199        BundleAddPayload {
1200            config_overrides: overrides,
1201            ..payload(bundle_id)
1202        }
1203    }
1204
1205    fn single_override(
1206        pack_id: &str,
1207        key: &str,
1208        value: Value,
1209    ) -> BTreeMap<String, BTreeMap<String, Value>> {
1210        BTreeMap::from([(
1211            pack_id.to_string(),
1212            BTreeMap::from([(key.to_string(), value)]),
1213        )])
1214    }
1215
1216    #[test]
1217    fn add_persists_config_overrides_on_bundle_deployment() {
1218        let dir = tempdir().unwrap();
1219        let store = LocalFsStore::new(dir.path());
1220        store.save(&make_env("local")).unwrap();
1221        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1222        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1223        let overrides = single_override(
1224            "messaging-telegram",
1225            "api_base_url",
1226            Value::String("https://staging.example.com".to_string()),
1227        );
1228        let outcome = add(
1229            &store,
1230            &OpFlags::default(),
1231            Some(payload_with_overrides("fast2flow", overrides.clone())),
1232        )
1233        .unwrap();
1234        assert_eq!(outcome.op, "add");
1235        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1236        assert_eq!(env.bundles[0].config_overrides, overrides);
1237    }
1238
1239    #[test]
1240    fn add_rejects_structurally_invalid_config_overrides() {
1241        // Spec-level validation (`BundleDeployment::validate`) catches empty
1242        // pack_id / key / size violations and propagates as `OpError::Spec`.
1243        let dir = tempdir().unwrap();
1244        let store = LocalFsStore::new(dir.path());
1245        store.save(&make_env("local")).unwrap();
1246        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1247        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1248        let overrides = BTreeMap::from([(
1249            String::new(), // empty pack_id → rejected by structural validation
1250            BTreeMap::from([("k".to_string(), Value::String("v".to_string()))]),
1251        )]);
1252        let err = add(
1253            &store,
1254            &OpFlags::default(),
1255            Some(payload_with_overrides("fast2flow", overrides)),
1256        )
1257        .unwrap_err();
1258        // Validation fires inside `store.save(&env)`, so the SpecError lands
1259        // wrapped in `OpError::Store(StoreError::Spec(_))`.
1260        let msg = format!("{err:?}");
1261        assert!(
1262            msg.contains("ConfigOverrideEmptyPackId"),
1263            "expected ConfigOverrideEmptyPackId, got {err:?}"
1264        );
1265    }
1266
1267    #[test]
1268    fn update_replaces_config_overrides_when_some() {
1269        let dir = tempdir().unwrap();
1270        let store = LocalFsStore::new(dir.path());
1271        store.save(&make_env("local")).unwrap();
1272        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1273        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1274        let initial = single_override(
1275            "messaging-telegram",
1276            "api_base_url",
1277            Value::String("https://v1.example.com".to_string()),
1278        );
1279        let added = add(
1280            &store,
1281            &OpFlags::default(),
1282            Some(payload_with_overrides("fast2flow", initial)),
1283        )
1284        .unwrap();
1285        let did = added
1286            .result
1287            .get("deployment_id")
1288            .and_then(|v| v.as_str())
1289            .unwrap()
1290            .to_string();
1291        let replaced = single_override(
1292            "messaging-telegram",
1293            "api_base_url",
1294            Value::String("https://v2.example.com".to_string()),
1295        );
1296        update(
1297            &store,
1298            &OpFlags::default(),
1299            Some(BundleUpdatePayload {
1300                environment_id: "local".to_string(),
1301                deployment_id: did,
1302                status: None,
1303                route_binding: None,
1304                revenue_share: None,
1305                config_overrides: Some(replaced.clone()),
1306                idempotency_key: None,
1307            }),
1308        )
1309        .unwrap();
1310        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1311        assert_eq!(env.bundles[0].config_overrides, replaced);
1312    }
1313
1314    #[test]
1315    fn update_with_none_leaves_config_overrides_untouched() {
1316        let dir = tempdir().unwrap();
1317        let store = LocalFsStore::new(dir.path());
1318        store.save(&make_env("local")).unwrap();
1319        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1320        crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1321        let initial = single_override(
1322            "messaging-slack",
1323            "webhook_url",
1324            Value::String("https://hooks.slack/v1".to_string()),
1325        );
1326        let added = add(
1327            &store,
1328            &OpFlags::default(),
1329            Some(payload_with_overrides("fast2flow", initial.clone())),
1330        )
1331        .unwrap();
1332        let did = added
1333            .result
1334            .get("deployment_id")
1335            .and_then(|v| v.as_str())
1336            .unwrap()
1337            .to_string();
1338        // Update status only — `config_overrides: None` must leave them alone.
1339        update(
1340            &store,
1341            &OpFlags::default(),
1342            Some(BundleUpdatePayload {
1343                environment_id: "local".to_string(),
1344                deployment_id: did,
1345                status: Some(BundleDeploymentStatus::Paused),
1346                route_binding: None,
1347                revenue_share: None,
1348                config_overrides: None,
1349                idempotency_key: None,
1350            }),
1351        )
1352        .unwrap();
1353        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1354        assert_eq!(env.bundles[0].config_overrides, initial);
1355    }
1356
1357    #[test]
1358    fn config_overrides_audit_shape_lists_keys_not_values() {
1359        // Values may carry secrets-adjacent material (URLs with tokens, etc.);
1360        // the audit shape must publish key NAMES only — never the values.
1361        let overrides = BTreeMap::from([
1362            (
1363                "messaging-telegram".to_string(),
1364                BTreeMap::from([
1365                    (
1366                        "api_base_url".to_string(),
1367                        Value::String("https://api.telegram.org/SECRET-TOKEN".to_string()),
1368                    ),
1369                    (
1370                        "retry_max".to_string(),
1371                        Value::Number(serde_json::Number::from(5)),
1372                    ),
1373                ]),
1374            ),
1375            (
1376                "messaging-slack".to_string(),
1377                BTreeMap::from([(
1378                    "webhook_url".to_string(),
1379                    Value::String("https://hooks.slack/T0123/B0456/SECRET-PATH".to_string()),
1380                )]),
1381            ),
1382        ]);
1383        let shape = config_overrides_audit_shape(&overrides);
1384        let serialized = serde_json::to_string(&shape).unwrap();
1385        assert!(
1386            !serialized.contains("SECRET"),
1387            "audit shape leaked a value: {serialized}"
1388        );
1389        assert!(
1390            serialized.contains("api_base_url") && serialized.contains("retry_max"),
1391            "audit shape missing key names: {serialized}"
1392        );
1393        // Keys are sorted (BTreeMap ordering), so the shape is deterministic.
1394        assert_eq!(
1395            serialized,
1396            r#"{"messaging-slack":["webhook_url"],"messaging-telegram":["api_base_url","retry_max"]}"#
1397        );
1398    }
1399}