Skip to main content

greentic_deployer/environment/
http_store.rs

1//! [`HttpEnvironmentStore`] — remote HTTP-backed implementation of
2//! [`EnvironmentMutations`].
3//!
4//! Talks to a future `greentic-operator-store-server` (PR-4) over the
5//! **A8 HTTP contract** specified in [`greentic_deploy_spec::remote`].
6//! JSON over the wire, `reqwest::blocking::Client` for transport so the
7//! sync `EnvironmentMutations` trait stays sync (no Tokio runtime needed
8//! at call sites — see project memory `project_next_gen_deployment_phase_b`
9//! for the `block_in_place` panic precedent that rules out async-in-sync).
10//!
11//! # Route table
12//!
13//! Every mutation maps to a single HTTP endpoint. The server (PR-4) mirrors
14//! this table.
15//!
16//! | Trait method                   | Method | Path                                                      |
17//! |-------------------------------|--------|-----------------------------------------------------------|
18//! | `create_environment`          | POST   | `/environments`                                           |
19//! | `update_environment`          | PATCH  | `/environments/{env_id}`                                  |
20//! | `migrate_merge_bindings`      | POST   | `/environments/{env_id}/migrate-bindings`                 |
21//! | `stage_revision`              | POST   | `/environments/{env_id}/revisions`                        |
22//! | `warm_revision`               | POST   | `/environments/{env_id}/revisions/{rid}/warm`             |
23//! | `drain_revision`              | POST   | `/environments/{env_id}/revisions/{rid}/drain`            |
24//! | `archive_revision`            | POST   | `/environments/{env_id}/revisions/{rid}/archive`          |
25//! | `add_bundle`                  | POST   | `/environments/{env_id}/bundles`                          |
26//! | `update_bundle`               | PATCH  | `/environments/{env_id}/bundles/{deployment_id}`          |
27//! | `remove_bundle`               | DELETE | `/environments/{env_id}/bundles/{deployment_id}`          |
28//! | `add_pack_binding`            | POST   | `/environments/{env_id}/packs`                            |
29//! | `update_pack_binding`         | PATCH  | `/environments/{env_id}/packs/{slot}`                     |
30//! | `remove_pack_binding`         | DELETE | `/environments/{env_id}/packs/{slot}`                     |
31//! | `rollback_pack_binding`       | POST   | `/environments/{env_id}/packs/{slot}/rollback`            |
32//! | `add_extension_binding`       | POST   | `/environments/{env_id}/extensions`                       |
33//! | `update_extension_binding`    | PATCH  | `/environments/{env_id}/extensions`                       |
34//! | `remove_extension_binding`    | DELETE | `/environments/{env_id}/extensions`                       |
35//! | `rollback_extension_binding`  | POST   | `/environments/{env_id}/extensions/rollback`              |
36//! | `set_traffic_split`           | POST   | `/environments/{env_id}/traffic`                          |
37//! | `rollback_traffic_split`      | POST   | `/environments/{env_id}/traffic/rollback`                 |
38//! | `add_messaging_endpoint`      | POST   | `/environments/{env_id}/messaging`                        |
39//! | `link_messaging_bundle`       | POST   | `/environments/{env_id}/messaging/{eid}/link`             |
40//! | `unlink_messaging_bundle`     | POST   | `/environments/{env_id}/messaging/{eid}/unlink`           |
41//! | `set_messaging_welcome_flow`  | POST   | `/environments/{env_id}/messaging/{eid}/welcome-flow`     |
42//! | `remove_messaging_endpoint`   | DELETE | `/environments/{env_id}/messaging/{eid}`                  |
43//! | `rotate_messaging_webhook_secret` | POST | `/environments/{env_id}/messaging/{eid}/rotate-secret` |
44//! | `bootstrap_trust_root`        | POST   | `/environments/{env_id}/trust-root/bootstrap`             |
45//! | `seed_trust_root_if_absent`   | POST   | `/environments/{env_id}/trust-root/seed`                  |
46//! | `add_trusted_key`             | POST   | `/environments/{env_id}/trust-root/keys`                  |
47//! | `remove_trusted_key`          | DELETE | `/environments/{env_id}/trust-root/keys/{key_id}`         |
48//! | `load_environment`            | GET    | `/environments/{env_id}`                                  |
49//!
50//! `load_environment` is the one READ verb (no idempotency key, no audit
51//! envelope) — the remote dispatch uses it to evaluate client-side
52//! preconditions such as the `warm` health-gate's expected lifecycle.
53//!
54//! The backup/restore group (A8 #5, PR-4.4) is server-only — `LocalFsStore`
55//! has no implementation, so these are inherent methods on
56//! [`HttpEnvironmentStore`], not `EnvironmentMutations` verbs:
57//!
58//! | Inherent method               | Method | Path                                                      |
59//! |-------------------------------|--------|-----------------------------------------------------------|
60//! | `create_backup`               | POST   | `/environments/{env_id}/backups`                          |
61//! | `list_backups`                | GET    | `/environments/{env_id}/backups`                          |
62//! | `delete_backup`               | DELETE | `/environments/{env_id}/backups/{backup_id}`              |
63//! | `restore`                     | POST   | `/environments/{env_id}/restore`                          |
64//!
65//! # Headers
66//!
67//! - `Content-Type: application/json` / `Accept: application/json` on every request.
68//! - `Authorization: Bearer <token>` when [`AuthMethod::Bearer`].
69//! - `Idempotency-Key: <ulid>` when the payload carries an [`IdempotencyKey`].
70//!
71//! # ETag / CAS
72//!
73//! The store does **ETag-chained optimistic concurrency** entirely at the
74//! transport layer — no change to the [`EnvironmentMutations`] trait or any
75//! CLI call site. Per environment, it remembers the last strong ETag it
76//! observed (from the [`MutationEnvelope`] of every successful mutation, and
77//! from [`load_environment`](EnvironmentMutations::load_environment)'s
78//! response) in [`Self::cached_etag`], and replays it as a quoted `If-Match`
79//! header on the next mutation against that env. The server (A8 §1) then
80//! rejects the write with `412` if any other writer advanced the environment
81//! since that ETag. The cache is keyed by env because one store represents a
82//! remote endpoint, not a single env.
83//!
84//! This catches the two windows a per-invocation CLI actually exposes:
85//! - **read-then-decide-then-write** within one command (e.g. `revisions
86//!   warm` reads the lifecycle, runs health checks, then writes) — the write
87//!   is pinned to the ETag the decision was based on;
88//! - **multi-write commands** (e.g. `op env apply`) — each write is pinned
89//!   to the ETag the previous write returned, so a concurrent writer slipping
90//!   between two of our writes is caught.
91//!
92//! A single-write command on a fresh store has no cached ETag, so it sends
93//! no `If-Match`; the server's own load-pinned generation (a torn-write
94//! guard) still applies. Cross-*invocation* lost-update protection (one
95//! operator overwriting another's separately-issued command) needs an
96//! explicit caller-supplied expectation and is a separate, opt-in follow-up.
97//! A `412`/`428` clears the cache so a stale ETag is never replayed.
98//!
99//! `LocalFsStore` needs none of this — its `flock` serialises writers — so
100//! CAS lives only here, not on the shared trait.
101//!
102//! # Error mapping
103//!
104//! Transport errors (connection refused, timeout, TLS handshake) map to
105//! `StoreError::Conflict("transport: ...")`. A dedicated
106//! `StoreError::Transport` variant would be cleaner but adding a new enum
107//! variant cascades into every `match` site — follow-up.
108//!
109//! # Follow-ups
110//!
111//! - Explicit cross-invocation CAS (caller-supplied `--expect-generation`)
112//! - `StoreError::Transport` variant
113//! - `AuthMethod::Mtls` for production (mTLS)
114//! - PR-3c wires dispatch between `LocalFsStore` and `HttpEnvironmentStore`
115
116use greentic_deploy_spec::{
117    AuditDecision, AuditEvent, AuditResult, BackupManifest, BindingGenerationOutcome,
118    BundleDeployment, BundleId, CapabilitySlot, DeploymentId, EnvId, EnvPackBinding, Environment,
119    EnvironmentHostConfig, EnvironmentRuntime, ExtensionBinding, ExtensionBindingPayload,
120    ExtensionKeyedPayload, IdempotencyKey, IdempotencyOutcome, MessagingBundleLinkPayload,
121    MessagingEndpoint, MessagingEndpointId, PackBindingPayload, RemoteStoreError, RestoreOutcome,
122    RestoreRequest, Revision, RevisionId, RollbackTrafficSplitPayload, RotateWebhookSecretPayload,
123    StateEtag,
124};
125use greentic_distributor_client::signing::TrustedKey;
126use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
127use reqwest::blocking::Client;
128use serde::{Deserialize, Serialize};
129use std::collections::HashMap;
130use std::sync::{Arc, Mutex};
131use url::Url;
132
133use super::reads::EnvironmentReads;
134
135use super::mutations::{
136    AddBundlePayload, AddMessagingEndpointPayload, AddTrustedKeyPayload, ApplyTrafficSplitOutcome,
137    EnvironmentMutations, ExtensionKey, MigrateMergePayload, RemoveBundleOutcome,
138    RevisionTransitionOutcome, RollbackTrafficSplitOutcome, SetMessagingWelcomeFlowPayload,
139    SetTrafficSplitPayload, StageRevisionPayload, TrustRootAddOutcome, TrustRootRemoveOutcome,
140    TrustRootSeed, UpdateBundlePayload, UpdateEnvironmentPayload, WarmRevisionPayload,
141};
142use super::store::StoreError;
143
144// ---------------------------------------------------------------------------
145// Auth
146// ---------------------------------------------------------------------------
147
148/// How the client authenticates to the remote store server.
149#[derive(Debug, Clone)]
150pub enum AuthMethod {
151    /// No authentication (dev/loopback).
152    None,
153    /// Bearer token (Phase A).
154    Bearer(String),
155    // mTLS deferred — note in module doc but not implemented here.
156}
157
158// ---------------------------------------------------------------------------
159// URL path-segment encoding (Fix 1: prevent path-traversal via dynamic IDs)
160// ---------------------------------------------------------------------------
161
162/// Characters that MUST be percent-encoded when interpolated into a URL path
163/// segment. Covers RFC 3986 reserved + unsafe characters that `Url::join`
164/// would otherwise interpret structurally (`/`, `?`, `#`, `..` via `.`).
165const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
166    .add(b' ')
167    .add(b'"')
168    .add(b'#')
169    .add(b'<')
170    .add(b'>')
171    .add(b'?')
172    .add(b'`')
173    .add(b'{')
174    .add(b'}')
175    .add(b'/')
176    .add(b'%')
177    .add(b'.');
178
179/// Percent-encode a dynamic identifier for safe interpolation into a URL path
180/// segment. Normal alphanumeric identifiers pass through unchanged; characters
181/// like `/`, `..`, `?`, `#` are escaped so they cannot alter the request path.
182fn encode_segment(s: &str) -> String {
183    utf8_percent_encode(s, PATH_SEGMENT_ENCODE_SET).to_string()
184}
185
186// ---------------------------------------------------------------------------
187// Construction errors
188// ---------------------------------------------------------------------------
189
190/// Errors that can occur when constructing an [`HttpEnvironmentStore`].
191#[derive(Debug, thiserror::Error)]
192pub enum ConstructionError {
193    /// Bearer auth over plaintext HTTP to a non-loopback host exposes the token.
194    #[error(
195        "bearer auth over http:// is only allowed to loopback hosts; \
196         got `{0}` — use https:// or AuthMethod::None"
197    )]
198    InsecureTransport(String),
199}
200
201// ---------------------------------------------------------------------------
202// HttpEnvironmentStore
203// ---------------------------------------------------------------------------
204
205/// Remote HTTP-backed implementation of [`EnvironmentMutations`].
206///
207/// See the module-level doc for the route table and design rationale.
208#[derive(Debug, Clone)]
209pub struct HttpEnvironmentStore {
210    client: Client,
211    base_url: Url,
212    /// Pre-rendered `Authorization: Bearer <token>` value, built once at
213    /// construction. `None` when [`AuthMethod::None`].
214    auth_header_value: Option<String>,
215    /// Last strong ETag observed per environment — from a successful mutation
216    /// envelope or a `load_environment` read — replayed as `If-Match` on the
217    /// next mutation against the *same* environment to chain optimistic-
218    /// concurrency checks across the calls of one CLI invocation (see the
219    /// module-level "ETag / CAS" doc). Keyed by `EnvId` (as a string) because
220    /// one store represents a remote endpoint, not a single env: every trait
221    /// method is env-scoped, so a single store-wide ETag could otherwise pin
222    /// one env's write to another env's validator. `Arc<Mutex<…>>` so the
223    /// store stays `Clone + Send + Sync`; the deployer CLI drives a single
224    /// store on one thread, so the lock is uncontended.
225    cached_etag: Arc<Mutex<HashMap<String, StateEtag>>>,
226}
227
228/// Ensure `base_url`'s path ends with `/` so [`Url::join`] treats relative
229/// paths as siblings of the base, not replacements of the last segment.
230/// Idempotent — called once at construction.
231fn normalize_base_url(mut base_url: Url) -> Url {
232    if !base_url.path().ends_with('/') {
233        let normalized = format!("{}/", base_url.path());
234        base_url.set_path(&normalized);
235    }
236    base_url
237}
238
239/// Whether `url` points at a loopback address (`127.0.0.0/8`, `::1`, or
240/// `localhost`). Used by the insecure-transport guard.
241fn is_loopback(url: &Url) -> bool {
242    match url.host_str() {
243        Some("localhost") => true,
244        Some(host) => host
245            .parse::<std::net::IpAddr>()
246            .map(|ip| ip.is_loopback())
247            .unwrap_or(false),
248        None => false,
249    }
250}
251
252/// Validate that bearer auth is not used over plaintext HTTP to non-loopback.
253fn validate_transport(url: &Url, auth: &AuthMethod) -> Result<(), ConstructionError> {
254    if let AuthMethod::Bearer(_) = auth
255        && url.scheme() == "http"
256    {
257        if is_loopback(url) {
258            tracing::warn!(
259                url = %url,
260                "bearer auth over http:// to loopback — acceptable for dev, \
261                 not for production"
262            );
263        } else {
264            return Err(ConstructionError::InsecureTransport(
265                url.host_str().unwrap_or("<none>").to_string(),
266            ));
267        }
268    }
269    Ok(())
270}
271
272impl HttpEnvironmentStore {
273    /// Build with the default `reqwest::blocking::Client`.
274    ///
275    /// Returns [`ConstructionError::InsecureTransport`] if `auth` is
276    /// [`AuthMethod::Bearer`] and `base_url` is `http://` to a non-loopback
277    /// host.
278    pub fn new(base_url: Url, auth: AuthMethod) -> Result<Self, ConstructionError> {
279        Self::with_client(Client::new(), base_url, auth)
280    }
281
282    /// Build with a caller-supplied client (custom timeouts, TLS config, etc.).
283    ///
284    /// Returns [`ConstructionError::InsecureTransport`] if `auth` is
285    /// [`AuthMethod::Bearer`] and `base_url` is `http://` to a non-loopback
286    /// host.
287    pub fn with_client(
288        client: Client,
289        base_url: Url,
290        auth: AuthMethod,
291    ) -> Result<Self, ConstructionError> {
292        validate_transport(&base_url, &auth)?;
293        let auth_header_value = match auth {
294            AuthMethod::Bearer(token) => Some(format!("Bearer {token}")),
295            AuthMethod::None => None,
296        };
297        Ok(Self {
298            client,
299            base_url: normalize_base_url(base_url),
300            auth_header_value,
301            cached_etag: Arc::new(Mutex::new(HashMap::new())),
302        })
303    }
304
305    /// The cached strong ETag for `env_id`, rendered as a quoted `If-Match`
306    /// header value (RFC 7232 strong validator), or `None` when nothing has
307    /// been observed yet for that env. See the module-level "ETag / CAS" doc.
308    fn if_match_header(&self, env_id: &EnvId) -> Option<String> {
309        self.cached_etag
310            .lock()
311            .expect("cached_etag mutex poisoned")
312            .get(env_id.as_str())
313            .map(StateEtag::header_value)
314    }
315
316    /// Record (or clear) `env_id`'s ETag to replay on its next mutation.
317    /// Called with the envelope ETag after every successful mutation and with
318    /// the GET ETag after `load_environment`; called with `None` to drop a
319    /// now-uncertain ETag after a mutation error.
320    fn remember_etag(&self, env_id: &EnvId, etag: Option<StateEtag>) {
321        let mut cache = self.cached_etag.lock().expect("cached_etag mutex poisoned");
322        match etag {
323            Some(etag) => {
324                cache.insert(env_id.as_str().to_string(), etag);
325            }
326            None => {
327                cache.remove(env_id.as_str());
328            }
329        }
330    }
331
332    // -----------------------------------------------------------------------
333    // Internal helpers
334    // -----------------------------------------------------------------------
335
336    /// Build a full URL by joining `path` onto `base_url`. The base is
337    /// trailing-slash-normalized in the constructor so [`Url::join`] resolves
338    /// `path` relative to the API root, not by replacing the last segment.
339    fn url(&self, path: &str) -> Result<Url, StoreError> {
340        self.base_url
341            .join(path)
342            .map_err(|e| StoreError::Conflict(format!("transport: invalid URL path `{path}`: {e}")))
343    }
344
345    /// Build the env-scoped request path: `environments/{env}{suffix}`. The
346    /// returned string is suitable for [`Self::send_mutation`] et al; pass
347    /// `""` for the env itself or `"/revisions"` (already-encoded) for
348    /// sub-resources. For multi-segment paths (e.g. `/revisions/{rid}/warm`),
349    /// callers still encode each dynamic segment themselves and inline the
350    /// `format!` — the helper is only a win for the env-only and
351    /// env+constant-suffix shapes.
352    fn env_path(&self, env_id: &EnvId, suffix: &str) -> String {
353        format!("environments/{}{suffix}", encode_segment(env_id.as_str()))
354    }
355
356    /// Send an HTTP request and parse the JSON response.
357    ///
358    /// - Sets `Content-Type` and `Accept` to `application/json`.
359    /// - Adds `Authorization: Bearer` if configured.
360    /// - Adds `Idempotency-Key` header when provided.
361    /// - Adds `If-Match` header when `if_match` is provided (the caller passes
362    ///   the cached strong ETag for mutations; reads pass `None`).
363    /// - On success (2xx), deserializes the body as `R`.
364    /// - On error, maps the HTTP status + body to [`StoreError`]. The cached
365    ///   ETag is left untouched here — [`Self::send_mutation`] owns clearing it
366    ///   on a mutation error (a read error must not invalidate a valid ETag).
367    fn send<P: Serialize, R: serde::de::DeserializeOwned>(
368        &self,
369        method: reqwest::Method,
370        path: &str,
371        idempotency_key: Option<&str>,
372        if_match: Option<&str>,
373        body: Option<&P>,
374    ) -> Result<R, StoreError> {
375        let url = self.url(path)?;
376        let mut builder = self
377            .client
378            .request(method, url)
379            .header("Content-Type", "application/json")
380            .header("Accept", "application/json");
381
382        if let Some(value) = &self.auth_header_value {
383            builder = builder.header("Authorization", value);
384        }
385        if let Some(key) = idempotency_key {
386            builder = builder.header("Idempotency-Key", key);
387        }
388        if let Some(etag) = if_match {
389            builder = builder.header("If-Match", etag);
390        }
391        if let Some(payload) = body {
392            builder = builder.json(payload);
393        }
394
395        let response = builder
396            .send()
397            .map_err(|e| StoreError::Conflict(format!("transport: {e}")))?;
398
399        let status = response.status();
400        if status.is_success() {
401            // After a 2xx the mutation may already be committed server-side.
402            // Decode failures must not look retriable-with-a-fresh-key — wrap
403            // them in `CommittedAfterSave`. Safe because `send_mutation` is
404            // `send`'s only caller, so the 2xx path is exclusively mutations.
405            if status == reqwest::StatusCode::NO_CONTENT {
406                return serde_json::from_str("null").map_err(|e| {
407                    committed_after_save(
408                        format!("transport: cannot deserialize 204 body: {e}"),
409                        idempotency_key,
410                    )
411                });
412            }
413            response.json::<R>().map_err(|e| {
414                committed_after_save(
415                    format!("transport: invalid response body: {e}"),
416                    idempotency_key,
417                )
418            })
419        } else {
420            Err(map_error_response(status, response))
421        }
422    }
423
424    /// Send a mutating request whose A8 response is a [`MutationEnvelope`]
425    /// wrapping the domain result alongside ETag/generation/idempotency
426    /// metadata. Replays the cached strong ETag as `If-Match`, caches the
427    /// post-commit ETag for the next mutation, enforces the A8 §4 audit
428    /// invariant on the success envelope (see [`MutationEnvelope::validated`])
429    /// against `expected_env` — the env the request targeted — then returns
430    /// only the domain `result`. The `generation`/`idempotency` envelope
431    /// fields are not yet surfaced to callers (PR-3b-fu).
432    fn send_mutation<P: Serialize, R: serde::de::DeserializeOwned>(
433        &self,
434        expected_env: &EnvId,
435        method: reqwest::Method,
436        path: &str,
437        idempotency_key: Option<&str>,
438        body: Option<&P>,
439    ) -> Result<R, StoreError> {
440        let if_match = self.if_match_header(expected_env);
441        match self.send::<P, MutationEnvelope<R>>(
442            method,
443            path,
444            idempotency_key,
445            if_match.as_deref(),
446            body,
447        ) {
448            Ok(envelope) => match envelope.etag.clone() {
449                // Chain the post-commit ETag onto the next mutation against
450                // this env in this invocation.
451                Some(etag) => {
452                    self.remember_etag(expected_env, Some(etag));
453                    envelope.validated(expected_env, idempotency_key)
454                }
455                // A8 makes `etag` non-optional on a mutation response
456                // (`MutationResponse.etag`); its absence on a 2xx is a contract
457                // violation. Because CAS now chains on it, silently continuing
458                // would disable lost-update protection — so reject loudly,
459                // mirroring the audit-record enforcement in `validated`. Drop
460                // any prior entry first so a later write re-pins from scratch.
461                None => {
462                    self.remember_etag(expected_env, None);
463                    Err(committed_after_save(
464                        "A8 mutation response omitted the required `etag`".to_string(),
465                        idempotency_key,
466                    ))
467                }
468            },
469            Err(err) => {
470                // A mutation error leaves the committed state uncertain — a
471                // rejected `If-Match` (412), but also a *committed-on-error*
472                // path like a failing `warm` health gate, which advances the
473                // server's state (and ETag) while returning an error. Drop this
474                // env's cached ETag so the next write re-pins via the server's
475                // load-pinned guard instead of replaying a now-stale validator.
476                self.remember_etag(expected_env, None);
477                Err(err)
478            }
479        }
480    }
481
482    /// [`send_mutation`](Self::send_mutation) variant with no request body.
483    fn send_mutation_no_body<R: serde::de::DeserializeOwned>(
484        &self,
485        expected_env: &EnvId,
486        method: reqwest::Method,
487        path: &str,
488        idempotency_key: Option<&str>,
489    ) -> Result<R, StoreError> {
490        self.send_mutation::<(), R>(expected_env, method, path, idempotency_key, None)
491    }
492
493    // -----------------------------------------------------------------------
494    // Backup / restore (A8 #5, PR-4.4) — server-only operations
495    // -----------------------------------------------------------------------
496    //
497    // `LocalFsStore` has no backup implementation (a local store is backed
498    // up by copying the directory), so these live as inherent methods
499    // rather than `EnvironmentMutations` verbs. The mutating three wear the
500    // standard A8 envelope (audit validation applies unchanged); `list` is
501    // a plain read.
502
503    /// `POST /environments/{env_id}/backups` — snapshot the environment's
504    /// stored state server-side; returns the contract's manifest.
505    pub fn create_backup(
506        &self,
507        env_id: &EnvId,
508        idempotency_key: &IdempotencyKey,
509    ) -> Result<BackupManifest, StoreError> {
510        self.send_mutation_no_body(
511            env_id,
512            reqwest::Method::POST,
513            &self.env_path(env_id, "/backups"),
514            Some(idempotency_key.as_str()),
515        )
516    }
517
518    /// `GET /environments/{env_id}/backups` — list backup manifests,
519    /// oldest first.
520    pub fn list_backups(&self, env_id: &EnvId) -> Result<Vec<BackupManifest>, StoreError> {
521        #[derive(Deserialize)]
522        struct BackupsResponse {
523            backups: Vec<BackupManifest>,
524        }
525        let response: BackupsResponse = self.send::<(), _>(
526            reqwest::Method::GET,
527            &self.env_path(env_id, "/backups"),
528            None,
529            None,
530            None,
531        )?;
532        Ok(response.backups)
533    }
534
535    /// `DELETE /environments/{env_id}/backups/{backup_id}` — drop one
536    /// backup (the server's per-env backup store is bounded; at the cap,
537    /// `create_backup` refuses until old ones are deleted).
538    pub fn delete_backup(
539        &self,
540        env_id: &EnvId,
541        backup_id: &str,
542        idempotency_key: &IdempotencyKey,
543    ) -> Result<(), StoreError> {
544        let path = format!(
545            "{}/{}",
546            self.env_path(env_id, "/backups"),
547            encode_segment(backup_id)
548        );
549        let _ack: serde_json::Value = self.send_mutation_no_body(
550            env_id,
551            reqwest::Method::DELETE,
552            &path,
553            Some(idempotency_key.as_str()),
554        )?;
555        Ok(())
556    }
557
558    /// `POST /environments/{env_id}/restore` — restore the environment
559    /// from a named backup. `request.precondition` MUST pin prior state
560    /// (the server answers 428 otherwise; a stale pin is a 412). The
561    /// returned outcome's `integrity` equals the backup's recorded digest,
562    /// and [`RestoreOutcome::etag`] is the restored state's strong
563    /// validator for the next CAS write.
564    pub fn restore(
565        &self,
566        env_id: &EnvId,
567        request: &RestoreRequest,
568        idempotency_key: &IdempotencyKey,
569    ) -> Result<RestoreOutcome, StoreError> {
570        self.send_mutation(
571            env_id,
572            reqwest::Method::POST,
573            &self.env_path(env_id, "/restore"),
574            Some(idempotency_key.as_str()),
575            Some(request),
576        )
577    }
578
579    /// `POST /environments/{env_id}/reconcile` — server-mediated reconcile
580    /// authorization. Asks the control-plane store to AUTHORIZE + AUDIT +
581    /// CAS-pin a reconcile and return the authorized [`Environment`] snapshot;
582    /// the operator still executes the k8s apply against the live cluster (the
583    /// store has no cluster access). The cached strong ETag — captured by a
584    /// prior [`load_environment`](EnvironmentReads::load_environment) read in
585    /// this invocation — is replayed as the MANDATORY `If-Match`, so the
586    /// snapshot returned is provably the revision the operator reviewed; a
587    /// concurrent advance is refused server-side with a 412 (surfaced here as
588    /// [`StoreError::Conflict`]). The reconcile writes no desired state, so the
589    /// returned generation/ETag echo the loaded revision unchanged.
590    ///
591    /// Callers MUST `load_environment` first: without a cached ETag the request
592    /// carries no `If-Match` and the server answers 428.
593    pub fn reconcile_environment(
594        &self,
595        env_id: &EnvId,
596        idempotency_key: &IdempotencyKey,
597    ) -> Result<Environment, StoreError> {
598        self.send_mutation_no_body(
599            env_id,
600            reqwest::Method::POST,
601            &self.env_path(env_id, "/reconcile"),
602            Some(idempotency_key.as_str()),
603        )
604    }
605
606    /// `POST /environments/{env_id}/messaging/{eid}/rotate-secret` carrying an
607    /// optional caller-supplied NEW `webhook_secret_ref` (raw `secret://`
608    /// URI). The control-plane store never mints secret material, so:
609    /// - `Some(ref)` — the operator has already provisioned the value in its
610    ///   own secrets plane; the server records the asserted ref and bumps the
611    ///   endpoint generation.
612    /// - `None` — the server cannot prove a rotation and answers 501.
613    ///
614    /// The [`EnvironmentMutations::rotate_messaging_webhook_secret`] trait
615    /// method delegates here with `None` (that generic surface carries no ref;
616    /// the new-ref path is remote-store-specific and reached through the
617    /// `--store-url` dispatch).
618    pub fn rotate_messaging_webhook_secret_to_ref(
619        &self,
620        env_id: &EnvId,
621        endpoint_id: MessagingEndpointId,
622        updated_by: String,
623        webhook_secret_ref: Option<String>,
624        idempotency_key: IdempotencyKey,
625    ) -> Result<MessagingEndpoint, StoreError> {
626        let idem_key = idempotency_key.as_str().to_string();
627        let req = RotateWebhookSecretPayload {
628            updated_by,
629            webhook_secret_ref,
630        };
631        self.send_mutation(
632            env_id,
633            reqwest::Method::POST,
634            &format!(
635                "environments/{}/messaging/{}/rotate-secret",
636                encode_segment(env_id.as_str()),
637                encode_segment(&endpoint_id.to_string()),
638            ),
639            Some(&idem_key),
640            Some(&req),
641        )
642    }
643
644    /// `GET /environments/{env_id}/trust-root` — the env's trusted-key set
645    /// (empty for an absent row, which the server treats as closed-by-default;
646    /// a missing ENV is still a 404 → [`StoreError::NotFound`]). Inherent
647    /// rather than on [`EnvironmentReads`] because trust roots are a separate
648    /// document with their own error type and have no `LocalFsStore`
649    /// equivalent on that trait.
650    pub fn load_trust_root_keys(&self, env_id: &EnvId) -> Result<Vec<TrustedKey>, StoreError> {
651        #[derive(Deserialize)]
652        struct TrustRootResponse {
653            keys: Vec<TrustedKey>,
654        }
655        let response: TrustRootResponse = self.send::<(), _>(
656            reqwest::Method::GET,
657            &self.env_path(env_id, "/trust-root"),
658            None,
659            None,
660            None,
661        )?;
662        Ok(response.keys)
663    }
664}
665
666impl EnvironmentReads for HttpEnvironmentStore {
667    /// `GET /environments` → the sorted env-id set (RBAC read-scope filtering
668    /// is applied server-side).
669    fn list_env_ids(&self) -> Result<Vec<EnvId>, StoreError> {
670        #[derive(Deserialize)]
671        struct EnvsResponse {
672            environments: Vec<EnvId>,
673        }
674        let response: EnvsResponse =
675            self.send::<(), _>(reqwest::Method::GET, "environments", None, None, None)?;
676        Ok(response.environments)
677    }
678
679    /// A `GET` of the env mapped to a boolean: 200 → present, 404 → absent.
680    fn env_exists(&self, env_id: &EnvId) -> Result<bool, StoreError> {
681        match self.load_env(env_id) {
682            Ok(_) => Ok(true),
683            Err(StoreError::NotFound(_)) => Ok(false),
684            Err(other) => Err(other),
685        }
686    }
687
688    /// The environment document. Delegates to the mutation-side read
689    /// [`EnvironmentMutations::load_environment`] (`GET
690    /// /environments/{env_id}`) so the env GET shape lives in one place; the
691    /// read verbs only project the returned document.
692    fn load_env(&self, env_id: &EnvId) -> Result<Environment, StoreError> {
693        EnvironmentMutations::load_environment(self, env_id)
694    }
695
696    /// `GET /environments/{env_id}/runtime` → the runtime host-config sidecar
697    /// (`null` when none has been written) so remote `env show` reports the
698    /// real runtime instead of conflating "absent" with "not exposed".
699    fn read_runtime(&self, env_id: &EnvId) -> Result<Option<EnvironmentRuntime>, StoreError> {
700        #[derive(Deserialize)]
701        struct GetRuntimeResponse {
702            runtime: Option<EnvironmentRuntime>,
703        }
704        let response: GetRuntimeResponse = self.send::<(), _>(
705            reqwest::Method::GET,
706            &self.env_path(env_id, "/runtime"),
707            None,
708            None,
709            None,
710        )?;
711        Ok(response.runtime)
712    }
713}
714
715/// PR-4.0 (F2): enforce the A8 §4 audit invariant on a success envelope.
716///
717/// The local path fails closed on audit via `cli::audit_and_record`; the
718/// remote path skips local audit because the server owns the durable record
719/// (A8 §4). That hand-off is only sound if the server actually returned the
720/// record — so a 2xx envelope missing it, or carrying one that contradicts
721/// the success (a `deny` decision, a non-`ok` result) or names a different
722/// environment than the request targeted, is a contract violation the client
723/// must reject rather than report as success.
724///
725/// `sent_idempotency_key`: when `Some(key)`, the audit record's
726/// `idempotency_key` must match — otherwise a stale record from a
727/// different request could satisfy the check. A8 §2 replays carry the
728/// SAME key by definition, so replays pass. When `None` (no current
729/// caller supplies one), the equality check is skipped.
730fn validate_success_audit(
731    audit: Option<&AuditEvent>,
732    expected_env: &EnvId,
733    sent_idempotency_key: Option<&str>,
734) -> Result<(), StoreError> {
735    let Some(audit) = audit else {
736        return Err(StoreError::Conflict(
737            "A8 contract violation: success response is missing the audit record (§4)".to_string(),
738        ));
739    };
740    if let AuditDecision::Deny { policy, reason } = &audit.authorization {
741        return Err(StoreError::Conflict(format!(
742            "A8 contract violation: success response carries a deny audit decision \
743             (policy `{policy}`: {reason}); a denial must be a 403"
744        )));
745    }
746    match &audit.result {
747        AuditResult::Ok => {}
748        other => {
749            return Err(StoreError::Conflict(format!(
750                "A8 contract violation: success response carries a non-ok audit result \
751                 ({other:?})"
752            )));
753        }
754    }
755    if audit.env_id != expected_env.as_str() {
756        return Err(StoreError::Conflict(format!(
757            "A8 contract violation: audit record names env `{}` but the request targeted \
758             env `{expected_env}`",
759            audit.env_id
760        )));
761    }
762    // Bind the audit record to the request via the idempotency key: a
763    // stale same-env record cannot carry the request's fresh ULID.
764    if let Some(sent) = sent_idempotency_key {
765        let audit_key = audit.idempotency_key.as_deref();
766        if audit_key != Some(sent) {
767            return Err(StoreError::Conflict(format!(
768                "A8 contract violation: audit record idempotency key `{}` does not match \
769                 the request's key `{sent}` — the record does not belong to this mutation",
770                audit_key.unwrap_or("<missing>")
771            )));
772        }
773    }
774    Ok(())
775}
776
777/// Wrap a post-2xx failure in [`StoreError::CommittedAfterSave`] with the
778/// shared replay guidance. After a 2xx status the mutation may already be
779/// committed server-side, so the error must not look retriable — re-running
780/// with a freshly minted idempotency key would double-apply, while the SAME
781/// key replays (A8 §2).
782fn committed_after_save(message: String, idempotency_key: Option<&str>) -> StoreError {
783    let replay = match idempotency_key {
784        Some(key) => format!(" — replay with Idempotency-Key `{key}` instead of re-applying"),
785        None => {
786            " — re-running with the SAME Idempotency-Key replays instead of re-applying".to_string()
787        }
788    };
789    StoreError::CommittedAfterSave(Box::new(StoreError::Conflict(format!(
790        "{message} — the server reported success (2xx), so the mutation may already be \
791         committed{replay}"
792    ))))
793}
794
795// ---------------------------------------------------------------------------
796// A8 mutation-response envelope
797// ---------------------------------------------------------------------------
798
799/// The A8 success envelope for mutating calls. The server returns the domain
800/// `result` alongside CAS/idempotency/audit metadata defined in
801/// [`greentic_deploy_spec::remote::MutationResponse`].
802///
803/// PR-3b parses the full envelope. The `etag` drives wire-layer CAS (cached
804/// and replayed as `If-Match`); `generation` awaits a future return-type
805/// extension (PR-3b-fu). The trait methods return bare domain types, so
806/// callers see only `result` — except `audit`, which
807/// [`validate_success_audit`] enforces on every success (PR-4.0/F2). It stays
808/// `Option` so a missing record is rejected with a precise contract-violation
809/// message instead of a generic serde
810/// deserialize error.
811#[derive(Debug, Deserialize)]
812struct MutationEnvelope<T> {
813    result: T,
814    // The post-commit strong validator — chained onto the next mutation's
815    // `If-Match` (see the module-level "ETag / CAS" doc).
816    #[serde(default)]
817    etag: Option<StateEtag>,
818    // Present per A8 but not yet surfaced to callers (PR-3b-fu).
819    #[serde(default)]
820    #[allow(dead_code)]
821    generation: Option<u64>,
822    #[serde(default)]
823    #[allow(dead_code)]
824    idempotency: Option<IdempotencyOutcome>,
825    #[serde(default)]
826    audit: Option<AuditEvent>,
827}
828
829impl<T> MutationEnvelope<T> {
830    /// Enforce the A8 §4 audit invariant (see [`validate_success_audit`])
831    /// and return the domain `result`. Consuming the envelope here couples
832    /// the invariant to the type: any future path that deserializes a
833    /// `MutationEnvelope` must go through `validated` to reach the result,
834    /// so the check cannot be silently skipped. Violations are wrapped in
835    /// [`StoreError::CommittedAfterSave`] — the envelope only exists after
836    /// a 2xx, so the mutation may already be committed server-side.
837    fn validated(
838        self,
839        expected_env: &EnvId,
840        sent_idempotency_key: Option<&str>,
841    ) -> Result<T, StoreError> {
842        validate_success_audit(self.audit.as_ref(), expected_env, sent_idempotency_key)
843            .map_err(|inner| committed_after_save(inner.to_string(), sent_idempotency_key))?;
844        Ok(self.result)
845    }
846}
847
848/// Mint a one-shot idempotency key for methods whose trait signature does
849/// not carry one. Delegates to [`crate::cli::mint_idempotency_key`] so the
850/// ULID-generation strategy stays in one place. Returns the inner string for
851/// header-value use; each call produces a unique ULID so retries by the
852/// caller are safe.
853fn mint_idempotency_key() -> String {
854    crate::cli::mint_idempotency_key().as_str().to_string()
855}
856
857/// Map an error HTTP response to [`StoreError`].
858///
859/// Tries to parse the body as [`RemoteStoreError`] (the A8 error contract);
860/// falls back to a generic `StoreError::Conflict` with the status code and
861/// raw body text.
862fn map_error_response(
863    status: reqwest::StatusCode,
864    response: reqwest::blocking::Response,
865) -> StoreError {
866    let body_text = response.text().unwrap_or_default();
867
868    // Try to parse as the A8 contract error shape.
869    if let Ok(remote_err) = serde_json::from_str::<RemoteStoreError>(&body_text) {
870        let actual = status.as_u16();
871        let expected = remote_err.http_status();
872        // A8 defines `Unauthorized` as 403; accept 401 (unauthenticated) as
873        // well since both express a denial. All other kinds must match exactly.
874        let consistent = actual == expected
875            || (actual == 401 && matches!(remote_err, RemoteStoreError::Unauthorized { .. }));
876        if consistent {
877            return map_remote_error(remote_err);
878        }
879        return StoreError::Conflict(format!(
880            "A8 contract violation: HTTP status {actual} contradicts the A8 error body \
881             (kind expects {expected}): {remote_err}"
882        ));
883    }
884
885    // Fallback: map by status code with raw body.
886    match status.as_u16() {
887        404 => StoreError::NotFound(EnvId::try_from("unknown").unwrap_or_else(|_| {
888            // EnvId::try_from should not fail for "unknown" but guard anyway.
889            unreachable!("EnvId::try_from(\"unknown\") must succeed")
890        })),
891        409 => StoreError::Conflict(body_text),
892        400 | 422 => StoreError::InvalidArgument(body_text),
893        // PR-4.0 (F4): a 401/403 whose body is not the A8 error shape (e.g.
894        // from a proxy/LB in front of the store) is still a denial — keep
895        // the `unauthorized` noun rather than degrading to `conflict`.
896        401 | 403 => StoreError::Unauthorized {
897            policy: GATEWAY_DENIAL_POLICY.to_string(),
898            reason: body_text,
899        },
900        501 => StoreError::NotYetImplemented(body_text),
901        _ => StoreError::Conflict(format!("server ({status}): {body_text}")),
902    }
903}
904
905/// `policy` value for denials that did not come through the A8 error shape
906/// (non-A8 401/403 bodies, e.g. a proxy/LB in front of the store). Named so
907/// downstream consumers matching on `policy` have one stable value.
908const GATEWAY_DENIAL_POLICY: &str = "remote";
909
910/// Map a parsed [`RemoteStoreError`] to [`StoreError`]. Takes the error by
911/// value so the owned `String` fields move instead of cloning.
912fn map_remote_error(err: RemoteStoreError) -> StoreError {
913    match err {
914        RemoteStoreError::NotFound => StoreError::NotFound(
915            EnvId::try_from("unknown")
916                .unwrap_or_else(|_| unreachable!("EnvId::try_from(\"unknown\") must succeed")),
917        ),
918        RemoteStoreError::PreconditionFailed(conflict) => {
919            StoreError::Conflict(format!("precondition failed: {conflict:?}"))
920        }
921        RemoteStoreError::PreconditionRequired { detail } => {
922            StoreError::Conflict(format!("precondition required: {detail}"))
923        }
924        RemoteStoreError::IdempotencyConflict { reason } => {
925            StoreError::Conflict(format!("idempotency conflict: {reason}"))
926        }
927        RemoteStoreError::Unauthorized { policy, reason } => {
928            StoreError::Unauthorized { policy, reason }
929        }
930        // Same noun the local impl uses for create-on-existing — the CLI
931        // mapper downcasts `Conflict` uniformly across backends.
932        RemoteStoreError::AlreadyExists { detail } => StoreError::Conflict(detail),
933        RemoteStoreError::Conflict { detail } => StoreError::Conflict(detail),
934        RemoteStoreError::DependentNotFound { detail } => StoreError::DependentNotFound(detail),
935        // Reconstruct the local store's typed health-gate failure so CLI
936        // callers (committed-on-error handling, gate-failed telemetry emit)
937        // behave identically against a remote store. The server persisted
938        // the `Failed` lifecycle before responding — committed, like local.
939        RemoteStoreError::HealthGateFailed {
940            revision_id,
941            failed_checks,
942            message,
943        } => StoreError::Lifecycle(Box::new(
944            crate::environment::LifecycleError::HealthGateFailed {
945                revision_id,
946                failed_checks,
947                message,
948            },
949        )),
950        RemoteStoreError::InvalidRequest { detail } => StoreError::InvalidArgument(detail),
951        RemoteStoreError::IntegrityMismatch { expected, actual } => StoreError::InvalidArgument(
952            format!("integrity mismatch: expected {expected}, computed {actual}"),
953        ),
954        RemoteStoreError::NotYetImplemented { detail } => StoreError::NotYetImplemented(detail),
955        RemoteStoreError::Internal { message } => {
956            StoreError::Conflict(format!("server: {message}"))
957        }
958    }
959}
960
961// ---------------------------------------------------------------------------
962// Wire types — request payloads sent to the server.
963//
964// The trait's payload structs (`StageRevisionPayload`, etc.) don't derive
965// `Serialize` (they are deployer-internal). We define thin wire DTOs here
966// that do, and convert at the call boundary.
967// ---------------------------------------------------------------------------
968
969// Env-lifecycle wire shapes (`CreateEnvironmentPayload`,
970// `UpdateEnvironmentPayload`, `MigrateMergePayload`, `MergeReport`) moved to
971// `greentic_deploy_spec::engine` in PR-4.2a; the revision verb group's
972// (`StageRevisionPayload`, `WarmRevisionPayload`,
973// `RevisionTransitionOutcome`) followed in PR-4.2b — the payload structs
974// now carry serde derives in the exact wire encoding this module
975// established, so the client serializes them directly and the
976// operator-store-server deserializes the same types. Remaining verb groups
977// migrate as their routes land.
978
979// Bundle wire shapes (`AddBundlePayload`, `UpdateBundlePayload`,
980// `RemoveBundleOutcome`) are the shared `greentic_deploy_spec::engine`
981// types since PR-4.2g — the client serializes the same structs the server
982// deserializes.
983
984// Binding wire shapes (`PackBindingPayload`, `ExtensionBindingPayload`,
985// `ExtensionKeyedPayload`, `BindingGenerationOutcome`) are the shared
986// `greentic_deploy_spec::engine` types since PR-4.2d — the client
987// serializes the same structs the server deserializes.
988
989// Traffic wire shapes (`SetTrafficSplitPayload`,
990// `RollbackTrafficSplitPayload`, `ApplyTrafficSplitOutcome`,
991// `RollbackTrafficSplitOutcome`) are the shared
992// `greentic_deploy_spec::engine` types since PR-4.2c — the client
993// serializes the same structs the server deserializes.
994
995// Messaging wire shapes (`AddMessagingEndpointPayload`,
996// `MessagingBundleLinkPayload`, `SetMessagingWelcomeFlowPayload`,
997// `RotateWebhookSecretPayload`) are the shared
998// `greentic_deploy_spec::engine` types since PR-4.2h — the client
999// serializes the same structs the server deserializes.
1000
1001// Trust-root wire shapes (`AddTrustedKeyPayload`, `TrustRootSeed`,
1002// `TrustRootAddOutcome`, `TrustRootRemoveOutcome`) are the shared
1003// `greentic_deploy_spec::engine` types since PR-4.2f — the client
1004// serializes/deserializes the same structs the server does.
1005
1006// ---------------------------------------------------------------------------
1007// EnvironmentMutations impl
1008// ---------------------------------------------------------------------------
1009
1010impl EnvironmentMutations for HttpEnvironmentStore {
1011    fn create_environment(
1012        &self,
1013        env_id: &EnvId,
1014        name: String,
1015        host_config: EnvironmentHostConfig,
1016    ) -> Result<Environment, StoreError> {
1017        let idem_key = mint_idempotency_key();
1018        let req = greentic_deploy_spec::CreateEnvironmentPayload {
1019            env_id: env_id.clone(),
1020            name,
1021            host_config,
1022        };
1023        self.send_mutation(
1024            env_id,
1025            reqwest::Method::POST,
1026            "environments",
1027            Some(&idem_key),
1028            Some(&req),
1029        )
1030    }
1031
1032    fn update_environment(
1033        &self,
1034        env_id: &EnvId,
1035        patch: UpdateEnvironmentPayload,
1036    ) -> Result<Environment, StoreError> {
1037        let idem_key = mint_idempotency_key();
1038        self.send_mutation(
1039            env_id,
1040            reqwest::Method::PATCH,
1041            &self.env_path(env_id, ""),
1042            Some(&idem_key),
1043            Some(&patch),
1044        )
1045    }
1046
1047    fn load_environment(&self, env_id: &EnvId) -> Result<Environment, StoreError> {
1048        // `GET /environments/{env_id}` → `GetEnvironmentResponse`. A read
1049        // carries no A8 audit envelope, so use the plain `send` — the mutation
1050        // helper would reject the (correctly) absent audit record.
1051        #[derive(serde::Deserialize)]
1052        struct GetEnvResponse {
1053            environment: Environment,
1054            // The strong validator for the loaded state. Captured so a write
1055            // later in the same invocation (e.g. the `warm` health gate that
1056            // read this lifecycle) is pinned to what it observed.
1057            #[serde(default)]
1058            etag: Option<StateEtag>,
1059        }
1060        let resp: GetEnvResponse = self.send::<(), _>(
1061            reqwest::Method::GET,
1062            &self.env_path(env_id, ""),
1063            None,
1064            None,
1065            None,
1066        )?;
1067        self.remember_etag(env_id, resp.etag);
1068        Ok(resp.environment)
1069    }
1070
1071    fn trust_root_is_seeded(&self, env_id: &EnvId) -> Result<bool, StoreError> {
1072        // `GET /environments/{env_id}/trust-root` → `{ keys: [...] }`. A read
1073        // carries no A8 audit envelope, so use the plain `send` (the mutation
1074        // helper would reject the correctly-absent audit record).
1075        #[derive(serde::Deserialize)]
1076        struct TrustRootView {
1077            keys: Vec<serde_json::Value>,
1078        }
1079        let resp: TrustRootView = self.send::<(), _>(
1080            reqwest::Method::GET,
1081            &self.env_path(env_id, "/trust-root"),
1082            None,
1083            None,
1084            None,
1085        )?;
1086        Ok(!resp.keys.is_empty())
1087    }
1088
1089    fn migrate_merge_bindings(
1090        &self,
1091        target_env_id: &EnvId,
1092        payload: MigrateMergePayload,
1093    ) -> Result<(Vec<String>, Vec<String>), StoreError> {
1094        let idem_key = mint_idempotency_key();
1095        let resp: greentic_deploy_spec::MergeReport = self.send_mutation(
1096            target_env_id,
1097            reqwest::Method::POST,
1098            &format!(
1099                "environments/{}/migrate-bindings",
1100                encode_segment(target_env_id.as_str())
1101            ),
1102            Some(&idem_key),
1103            Some(&payload),
1104        )?;
1105        Ok((resp.merged_slots, resp.merged_extensions))
1106    }
1107
1108    fn stage_revision(
1109        &self,
1110        env_id: &EnvId,
1111        payload: StageRevisionPayload,
1112        idempotency_key: IdempotencyKey,
1113    ) -> Result<Revision, StoreError> {
1114        let idem_key = idempotency_key.as_str().to_string();
1115        self.send_mutation(
1116            env_id,
1117            reqwest::Method::POST,
1118            &self.env_path(env_id, "/revisions"),
1119            Some(&idem_key),
1120            Some(&payload),
1121        )
1122    }
1123
1124    fn warm_revision(
1125        &self,
1126        env_id: &EnvId,
1127        payload: WarmRevisionPayload,
1128        idempotency_key: IdempotencyKey,
1129    ) -> Result<RevisionTransitionOutcome, StoreError> {
1130        let idem_key = idempotency_key.as_str().to_string();
1131        let rid = payload.revision_id;
1132        self.send_mutation(
1133            env_id,
1134            reqwest::Method::POST,
1135            &format!(
1136                "environments/{}/revisions/{}/warm",
1137                encode_segment(env_id.as_str()),
1138                encode_segment(&rid.to_string()),
1139            ),
1140            Some(&idem_key),
1141            Some(&payload),
1142        )
1143    }
1144
1145    fn drain_revision(
1146        &self,
1147        env_id: &EnvId,
1148        revision_id: RevisionId,
1149        idempotency_key: IdempotencyKey,
1150    ) -> Result<RevisionTransitionOutcome, StoreError> {
1151        let idem_key = idempotency_key.as_str().to_string();
1152        self.send_mutation_no_body(
1153            env_id,
1154            reqwest::Method::POST,
1155            &format!(
1156                "environments/{}/revisions/{}/drain",
1157                encode_segment(env_id.as_str()),
1158                encode_segment(&revision_id.to_string()),
1159            ),
1160            Some(&idem_key),
1161        )
1162    }
1163
1164    fn archive_revision(
1165        &self,
1166        env_id: &EnvId,
1167        revision_id: RevisionId,
1168        idempotency_key: IdempotencyKey,
1169    ) -> Result<RevisionTransitionOutcome, StoreError> {
1170        let idem_key = idempotency_key.as_str().to_string();
1171        self.send_mutation_no_body(
1172            env_id,
1173            reqwest::Method::POST,
1174            &format!(
1175                "environments/{}/revisions/{}/archive",
1176                encode_segment(env_id.as_str()),
1177                encode_segment(&revision_id.to_string()),
1178            ),
1179            Some(&idem_key),
1180        )
1181    }
1182
1183    fn add_bundle(
1184        &self,
1185        env_id: &EnvId,
1186        payload: AddBundlePayload,
1187        idempotency_key: IdempotencyKey,
1188    ) -> Result<BundleDeployment, StoreError> {
1189        let idem_key = idempotency_key.as_str().to_string();
1190        self.send_mutation(
1191            env_id,
1192            reqwest::Method::POST,
1193            &self.env_path(env_id, "/bundles"),
1194            Some(&idem_key),
1195            Some(&payload),
1196        )
1197    }
1198
1199    fn update_bundle(
1200        &self,
1201        env_id: &EnvId,
1202        payload: UpdateBundlePayload,
1203        idempotency_key: IdempotencyKey,
1204    ) -> Result<BundleDeployment, StoreError> {
1205        let idem_key = idempotency_key.as_str().to_string();
1206        let did = payload.deployment_id.to_string();
1207        self.send_mutation(
1208            env_id,
1209            reqwest::Method::PATCH,
1210            &format!(
1211                "environments/{}/bundles/{}",
1212                encode_segment(env_id.as_str()),
1213                encode_segment(&did),
1214            ),
1215            Some(&idem_key),
1216            Some(&payload),
1217        )
1218    }
1219
1220    fn remove_bundle(
1221        &self,
1222        env_id: &EnvId,
1223        deployment_id: DeploymentId,
1224        idempotency_key: IdempotencyKey,
1225    ) -> Result<RemoveBundleOutcome, StoreError> {
1226        let idem_key = idempotency_key.as_str().to_string();
1227        self.send_mutation_no_body(
1228            env_id,
1229            reqwest::Method::DELETE,
1230            &format!(
1231                "environments/{}/bundles/{}",
1232                encode_segment(env_id.as_str()),
1233                encode_segment(&deployment_id.to_string()),
1234            ),
1235            Some(&idem_key),
1236        )
1237    }
1238
1239    fn add_pack_binding(
1240        &self,
1241        env_id: &EnvId,
1242        binding: EnvPackBinding,
1243        idempotency_key: IdempotencyKey,
1244    ) -> Result<EnvPackBinding, StoreError> {
1245        let idem_key = idempotency_key.as_str().to_string();
1246        let req = PackBindingPayload { binding };
1247        self.send_mutation(
1248            env_id,
1249            reqwest::Method::POST,
1250            &self.env_path(env_id, "/packs"),
1251            Some(&idem_key),
1252            Some(&req),
1253        )
1254    }
1255
1256    fn update_pack_binding(
1257        &self,
1258        env_id: &EnvId,
1259        slot: CapabilitySlot,
1260        binding: EnvPackBinding,
1261        idempotency_key: IdempotencyKey,
1262    ) -> Result<(EnvPackBinding, u64), StoreError> {
1263        let idem_key = idempotency_key.as_str().to_string();
1264        let req = PackBindingPayload { binding };
1265        let resp: BindingGenerationOutcome<EnvPackBinding> = self.send_mutation(
1266            env_id,
1267            reqwest::Method::PATCH,
1268            &format!(
1269                "environments/{}/packs/{}",
1270                encode_segment(env_id.as_str()),
1271                encode_segment(&slot.to_string()),
1272            ),
1273            Some(&idem_key),
1274            Some(&req),
1275        )?;
1276        Ok((resp.binding, resp.generation))
1277    }
1278
1279    fn remove_pack_binding(
1280        &self,
1281        env_id: &EnvId,
1282        slot: CapabilitySlot,
1283        idempotency_key: IdempotencyKey,
1284    ) -> Result<(EnvPackBinding, u64), StoreError> {
1285        let idem_key = idempotency_key.as_str().to_string();
1286        let resp: BindingGenerationOutcome<EnvPackBinding> = self.send_mutation_no_body(
1287            env_id,
1288            reqwest::Method::DELETE,
1289            &format!(
1290                "environments/{}/packs/{}",
1291                encode_segment(env_id.as_str()),
1292                encode_segment(&slot.to_string()),
1293            ),
1294            Some(&idem_key),
1295        )?;
1296        Ok((resp.binding, resp.generation))
1297    }
1298
1299    fn rollback_pack_binding(
1300        &self,
1301        env_id: &EnvId,
1302        slot: CapabilitySlot,
1303        idempotency_key: IdempotencyKey,
1304    ) -> Result<(EnvPackBinding, u64), StoreError> {
1305        let idem_key = idempotency_key.as_str().to_string();
1306        let resp: BindingGenerationOutcome<EnvPackBinding> = self.send_mutation_no_body(
1307            env_id,
1308            reqwest::Method::POST,
1309            &format!(
1310                "environments/{}/packs/{}/rollback",
1311                encode_segment(env_id.as_str()),
1312                encode_segment(&slot.to_string()),
1313            ),
1314            Some(&idem_key),
1315        )?;
1316        Ok((resp.binding, resp.generation))
1317    }
1318
1319    fn add_extension_binding(
1320        &self,
1321        env_id: &EnvId,
1322        binding: ExtensionBinding,
1323        idempotency_key: IdempotencyKey,
1324    ) -> Result<ExtensionBinding, StoreError> {
1325        let idem_key = idempotency_key.as_str().to_string();
1326        let req = ExtensionBindingPayload { binding };
1327        self.send_mutation(
1328            env_id,
1329            reqwest::Method::POST,
1330            &format!(
1331                "environments/{}/extensions",
1332                encode_segment(env_id.as_str())
1333            ),
1334            Some(&idem_key),
1335            Some(&req),
1336        )
1337    }
1338
1339    fn update_extension_binding(
1340        &self,
1341        env_id: &EnvId,
1342        key: ExtensionKey,
1343        binding: ExtensionBinding,
1344        idempotency_key: IdempotencyKey,
1345    ) -> Result<(ExtensionBinding, u64), StoreError> {
1346        let idem_key = idempotency_key.as_str().to_string();
1347        let req = ExtensionKeyedPayload {
1348            key,
1349            binding: Some(binding),
1350        };
1351        let resp: BindingGenerationOutcome<ExtensionBinding> = self.send_mutation(
1352            env_id,
1353            reqwest::Method::PATCH,
1354            &format!(
1355                "environments/{}/extensions",
1356                encode_segment(env_id.as_str())
1357            ),
1358            Some(&idem_key),
1359            Some(&req),
1360        )?;
1361        Ok((resp.binding, resp.generation))
1362    }
1363
1364    fn remove_extension_binding(
1365        &self,
1366        env_id: &EnvId,
1367        key: ExtensionKey,
1368        idempotency_key: IdempotencyKey,
1369    ) -> Result<(ExtensionBinding, u64), StoreError> {
1370        let idem_key = idempotency_key.as_str().to_string();
1371        let req = ExtensionKeyedPayload { key, binding: None };
1372        let resp: BindingGenerationOutcome<ExtensionBinding> = self.send_mutation(
1373            env_id,
1374            reqwest::Method::DELETE,
1375            &format!(
1376                "environments/{}/extensions",
1377                encode_segment(env_id.as_str())
1378            ),
1379            Some(&idem_key),
1380            Some(&req),
1381        )?;
1382        Ok((resp.binding, resp.generation))
1383    }
1384
1385    fn rollback_extension_binding(
1386        &self,
1387        env_id: &EnvId,
1388        key: ExtensionKey,
1389        idempotency_key: IdempotencyKey,
1390    ) -> Result<(ExtensionBinding, u64), StoreError> {
1391        let idem_key = idempotency_key.as_str().to_string();
1392        let req = ExtensionKeyedPayload { key, binding: None };
1393        let resp: BindingGenerationOutcome<ExtensionBinding> = self.send_mutation(
1394            env_id,
1395            reqwest::Method::POST,
1396            &format!(
1397                "environments/{}/extensions/rollback",
1398                encode_segment(env_id.as_str())
1399            ),
1400            Some(&idem_key),
1401            Some(&req),
1402        )?;
1403        Ok((resp.binding, resp.generation))
1404    }
1405
1406    fn set_traffic_split(
1407        &self,
1408        env_id: &EnvId,
1409        payload: SetTrafficSplitPayload,
1410        idempotency_key: IdempotencyKey,
1411    ) -> Result<ApplyTrafficSplitOutcome, StoreError> {
1412        let idem_key = idempotency_key.as_str().to_string();
1413        self.send_mutation(
1414            env_id,
1415            reqwest::Method::POST,
1416            &self.env_path(env_id, "/traffic"),
1417            Some(&idem_key),
1418            Some(&payload),
1419        )
1420    }
1421
1422    fn rollback_traffic_split(
1423        &self,
1424        env_id: &EnvId,
1425        deployment_id: DeploymentId,
1426        idempotency_key: IdempotencyKey,
1427    ) -> Result<RollbackTrafficSplitOutcome, StoreError> {
1428        let idem_key = idempotency_key.as_str().to_string();
1429        let req = RollbackTrafficSplitPayload { deployment_id };
1430        self.send_mutation(
1431            env_id,
1432            reqwest::Method::POST,
1433            &format!(
1434                "environments/{}/traffic/rollback",
1435                encode_segment(env_id.as_str())
1436            ),
1437            Some(&idem_key),
1438            Some(&req),
1439        )
1440    }
1441
1442    fn add_messaging_endpoint(
1443        &self,
1444        env_id: &EnvId,
1445        payload: AddMessagingEndpointPayload,
1446        idempotency_key: IdempotencyKey,
1447    ) -> Result<MessagingEndpoint, StoreError> {
1448        let idem_key = idempotency_key.as_str().to_string();
1449        self.send_mutation(
1450            env_id,
1451            reqwest::Method::POST,
1452            &self.env_path(env_id, "/messaging"),
1453            Some(&idem_key),
1454            Some(&payload),
1455        )
1456    }
1457
1458    fn link_messaging_bundle(
1459        &self,
1460        env_id: &EnvId,
1461        endpoint_id: MessagingEndpointId,
1462        bundle_id: BundleId,
1463        updated_by: String,
1464        idempotency_key: IdempotencyKey,
1465    ) -> Result<MessagingEndpoint, StoreError> {
1466        let idem_key = idempotency_key.as_str().to_string();
1467        let req = MessagingBundleLinkPayload {
1468            bundle_id,
1469            updated_by,
1470        };
1471        self.send_mutation(
1472            env_id,
1473            reqwest::Method::POST,
1474            &format!(
1475                "environments/{}/messaging/{}/link",
1476                encode_segment(env_id.as_str()),
1477                encode_segment(&endpoint_id.to_string()),
1478            ),
1479            Some(&idem_key),
1480            Some(&req),
1481        )
1482    }
1483
1484    fn unlink_messaging_bundle(
1485        &self,
1486        env_id: &EnvId,
1487        endpoint_id: MessagingEndpointId,
1488        bundle_id: BundleId,
1489        updated_by: String,
1490        idempotency_key: IdempotencyKey,
1491    ) -> Result<MessagingEndpoint, StoreError> {
1492        let idem_key = idempotency_key.as_str().to_string();
1493        let req = MessagingBundleLinkPayload {
1494            bundle_id,
1495            updated_by,
1496        };
1497        self.send_mutation(
1498            env_id,
1499            reqwest::Method::POST,
1500            &format!(
1501                "environments/{}/messaging/{}/unlink",
1502                encode_segment(env_id.as_str()),
1503                encode_segment(&endpoint_id.to_string()),
1504            ),
1505            Some(&idem_key),
1506            Some(&req),
1507        )
1508    }
1509
1510    fn set_messaging_welcome_flow(
1511        &self,
1512        env_id: &EnvId,
1513        payload: SetMessagingWelcomeFlowPayload,
1514        idempotency_key: IdempotencyKey,
1515    ) -> Result<MessagingEndpoint, StoreError> {
1516        let idem_key = idempotency_key.as_str().to_string();
1517        let eid = payload.endpoint_id.to_string();
1518        self.send_mutation(
1519            env_id,
1520            reqwest::Method::POST,
1521            &format!(
1522                "environments/{}/messaging/{}/welcome-flow",
1523                encode_segment(env_id.as_str()),
1524                encode_segment(&eid),
1525            ),
1526            Some(&idem_key),
1527            Some(&payload),
1528        )
1529    }
1530
1531    fn remove_messaging_endpoint(
1532        &self,
1533        env_id: &EnvId,
1534        endpoint_id: MessagingEndpointId,
1535    ) -> Result<MessagingEndpointId, StoreError> {
1536        let idem_key = mint_idempotency_key();
1537        self.send_mutation_no_body(
1538            env_id,
1539            reqwest::Method::DELETE,
1540            &format!(
1541                "environments/{}/messaging/{}",
1542                encode_segment(env_id.as_str()),
1543                encode_segment(&endpoint_id.to_string()),
1544            ),
1545            Some(&idem_key),
1546        )
1547    }
1548
1549    fn rotate_messaging_webhook_secret(
1550        &self,
1551        env_id: &EnvId,
1552        endpoint_id: MessagingEndpointId,
1553        updated_by: String,
1554        idempotency_key: IdempotencyKey,
1555    ) -> Result<MessagingEndpoint, StoreError> {
1556        // The generic mutation surface carries no caller ref; the new-ref
1557        // rotation is remote-store-specific and goes through the inherent
1558        // `rotate_messaging_webhook_secret_to_ref` from the `--store-url`
1559        // dispatch. A no-ref rotation is refused by the server (501).
1560        self.rotate_messaging_webhook_secret_to_ref(
1561            env_id,
1562            endpoint_id,
1563            updated_by,
1564            None,
1565            idempotency_key,
1566        )
1567    }
1568
1569    fn bootstrap_trust_root(&self, env_id: &EnvId) -> Result<TrustRootSeed, StoreError> {
1570        let idem_key = mint_idempotency_key();
1571        self.send_mutation_no_body(
1572            env_id,
1573            reqwest::Method::POST,
1574            &self.env_path(env_id, "/trust-root/bootstrap"),
1575            Some(&idem_key),
1576        )
1577    }
1578
1579    fn seed_trust_root_if_absent(
1580        &self,
1581        env_id: &EnvId,
1582    ) -> Result<Option<TrustRootSeed>, StoreError> {
1583        let idem_key = mint_idempotency_key();
1584        self.send_mutation_no_body(
1585            env_id,
1586            reqwest::Method::POST,
1587            &self.env_path(env_id, "/trust-root/seed"),
1588            Some(&idem_key),
1589        )
1590    }
1591
1592    fn add_trusted_key(
1593        &self,
1594        env_id: &EnvId,
1595        key_id: String,
1596        public_key_pem: String,
1597        idempotency_key: IdempotencyKey,
1598    ) -> Result<TrustRootAddOutcome, StoreError> {
1599        let idem_key = idempotency_key.as_str().to_string();
1600        let req = AddTrustedKeyPayload {
1601            key_id,
1602            public_key_pem,
1603        };
1604        self.send_mutation(
1605            env_id,
1606            reqwest::Method::POST,
1607            &self.env_path(env_id, "/trust-root/keys"),
1608            Some(&idem_key),
1609            Some(&req),
1610        )
1611    }
1612
1613    fn remove_trusted_key(
1614        &self,
1615        env_id: &EnvId,
1616        key_id: String,
1617        idempotency_key: IdempotencyKey,
1618    ) -> Result<TrustRootRemoveOutcome, StoreError> {
1619        let idem_key = idempotency_key.as_str().to_string();
1620        self.send_mutation_no_body(
1621            env_id,
1622            reqwest::Method::DELETE,
1623            &format!(
1624                "environments/{}/trust-root/keys/{}",
1625                encode_segment(env_id.as_str()),
1626                encode_segment(&key_id),
1627            ),
1628            Some(&idem_key),
1629        )
1630    }
1631}
1632
1633#[cfg(test)]
1634mod tests {
1635    use super::*;
1636    use greentic_deploy_spec::{PackId, RevisionLifecycle};
1637    use std::io::{BufRead, BufReader, Write};
1638    use std::net::{SocketAddr, TcpListener};
1639    use std::path::PathBuf;
1640    use std::sync::{Arc, Mutex};
1641
1642    /// Minimal mock server: binds an ephemeral port, accepts one request,
1643    /// validates it with `check`, and responds with the given status + body.
1644    struct MockServer {
1645        addr: SocketAddr,
1646        _handle: std::thread::JoinHandle<()>,
1647    }
1648
1649    type CheckFn = Arc<dyn Fn(&str, &str, &[u8]) + Send + Sync>;
1650
1651    /// A mock that serves multiple sequential requests.
1652    fn start_mock(responses: Vec<(u16, &str)>, check: Option<CheckFn>) -> MockServer {
1653        let responses: Vec<(u16, String)> = responses
1654            .into_iter()
1655            .map(|(s, b)| (s, b.to_string()))
1656            .collect();
1657        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1658        let addr = listener.local_addr().unwrap();
1659        let handle = std::thread::spawn(move || {
1660            for (status, body) in responses {
1661                let (stream, _) = listener.accept().unwrap();
1662                // Use a single BufReader for both headers and body so buffered
1663                // bytes are not lost between the header scan and the body read.
1664                let mut reader = BufReader::new(stream);
1665                let mut lines: Vec<String> = Vec::new();
1666                loop {
1667                    let mut line = String::new();
1668                    reader.read_line(&mut line).unwrap();
1669                    let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
1670                    if trimmed.is_empty() {
1671                        break;
1672                    }
1673                    lines.push(trimmed.to_string());
1674                }
1675                // Read body if Content-Length present (same buffered reader).
1676                let content_length: usize = lines
1677                    .iter()
1678                    .find(|l| l.to_lowercase().starts_with("content-length:"))
1679                    .and_then(|l| l.split(':').nth(1))
1680                    .and_then(|v| v.trim().parse().ok())
1681                    .unwrap_or(0);
1682                let mut req_body = vec![0u8; content_length];
1683                if content_length > 0 {
1684                    std::io::Read::read_exact(&mut reader, &mut req_body).unwrap();
1685                }
1686
1687                if let Some(ref check_fn) = check {
1688                    let request_line = lines.first().map(|s| s.as_str()).unwrap_or("");
1689                    let headers = lines[1..].join("\r\n");
1690                    check_fn(request_line, &headers, &req_body);
1691                }
1692
1693                // Extract the Idempotency-Key header value from the request
1694                // and substitute `{{IDEMPOTENCY_KEY}}` in the response body
1695                // so happy-path tests echo the real sent key back in the
1696                // audit record (FIX 1 correlation).
1697                let body = if body.contains("{{IDEMPOTENCY_KEY}}") {
1698                    let idem_val = lines
1699                        .iter()
1700                        .find(|l| l.to_lowercase().starts_with("idempotency-key:"))
1701                        .and_then(|l| l.split_once(':').map(|(_, v)| v.trim().to_string()))
1702                        .unwrap_or_default();
1703                    body.replace("{{IDEMPOTENCY_KEY}}", &idem_val)
1704                } else {
1705                    body
1706                };
1707
1708                let status_text = match status {
1709                    200 => "OK",
1710                    201 => "Created",
1711                    204 => "No Content",
1712                    400 => "Bad Request",
1713                    401 => "Unauthorized",
1714                    403 => "Forbidden",
1715                    404 => "Not Found",
1716                    409 => "Conflict",
1717                    422 => "Unprocessable Entity",
1718                    500 => "Internal Server Error",
1719                    501 => "Not Implemented",
1720                    _ => "Unknown",
1721                };
1722                // Recompute Content-Length after substitution.
1723                let response = format!(
1724                    "HTTP/1.1 {status} {status_text}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1725                    body.len()
1726                );
1727                let stream_ref = reader.get_mut();
1728                stream_ref.write_all(response.as_bytes()).unwrap();
1729                stream_ref.flush().unwrap();
1730            }
1731        });
1732        MockServer {
1733            addr,
1734            _handle: handle,
1735        }
1736    }
1737
1738    fn mock_store(addr: SocketAddr, auth: AuthMethod) -> HttpEnvironmentStore {
1739        HttpEnvironmentStore::new(Url::parse(&format!("http://{addr}")).unwrap(), auth).unwrap()
1740    }
1741
1742    /// One-shot helper for the common no-auth single-response test shape:
1743    /// start a mock that returns `(status, body)` once and build a store
1744    /// pointed at it. Callers must hold the returned `MockServer` alive
1745    /// (binding it; dropping closes the listener mid-test).
1746    fn happy_store(status: u16, body: &str) -> (MockServer, HttpEnvironmentStore) {
1747        let mock = start_mock(vec![(status, body)], None);
1748        let store = mock_store(mock.addr, AuthMethod::None);
1749        (mock, store)
1750    }
1751
1752    /// Wrap a domain-type JSON value in the A8 `MutationEnvelope` shape.
1753    fn wrap_mutation(domain: serde_json::Value) -> String {
1754        serde_json::json!({
1755            "result": domain,
1756            "etag": "sha256:test",
1757            "generation": 1,
1758            "idempotency": {"idempotency": "applied"},
1759            "audit": {
1760                "schema": "greentic.audit-event.v1",
1761                "event_id": "01TEST000000000000000000AA",
1762                "ts": "2026-06-09T12:00:00Z",
1763                "actor": {"kind": "operator"},
1764                "env_id": "local",
1765                "noun": "test",
1766                "verb": "test",
1767                "target": null,
1768                "authorization": {"decision": "allow", "policy": "local-only", "reason": "test"},
1769                "result": {"outcome": "ok"},
1770                "idempotency_key": "{{IDEMPOTENCY_KEY}}"
1771            }
1772        })
1773        .to_string()
1774    }
1775
1776    fn env_id() -> EnvId {
1777        EnvId::try_from("local").unwrap()
1778    }
1779
1780    fn idem() -> IdempotencyKey {
1781        IdempotencyKey::new("01JABC000000000000000000ZZ").unwrap()
1782    }
1783
1784    /// A minimal valid `Environment` domain body for envelope-shape tests.
1785    fn sample_env_domain() -> serde_json::Value {
1786        serde_json::json!({
1787            "schema": "greentic.environment.v1",
1788            "environment_id": "local",
1789            "name": "test",
1790            "host_config": {"env_id": "local"},
1791            "packs": [],
1792            "bundles": [],
1793            "revisions": [],
1794            "traffic_splits": [],
1795            "messaging_endpoints": [],
1796            "extensions": [],
1797            "revocation": {},
1798            "retention": {},
1799            "health": {}
1800        })
1801    }
1802
1803    /// [`wrap_mutation`] variant with the envelope mutated by `tweak` after
1804    /// assembly — for the PR-4.0/F2 audit-invariant negative tests.
1805    fn wrap_mutation_tweaked(
1806        domain: serde_json::Value,
1807        tweak: impl FnOnce(&mut serde_json::Value),
1808    ) -> String {
1809        let mut envelope: serde_json::Value = serde_json::from_str(&wrap_mutation(domain)).unwrap();
1810        tweak(&mut envelope);
1811        envelope.to_string()
1812    }
1813
1814    // -----------------------------------------------------------------------
1815    // PR-4.0 (F2): A8 §4 audit invariant on success envelopes
1816    // -----------------------------------------------------------------------
1817
1818    /// Helper: unwrap a `CommittedAfterSave` wrapper and return the inner
1819    /// `Conflict` message string. Panics if the shape doesn't match.
1820    fn unwrap_committed_conflict(result: Result<Environment, StoreError>) -> String {
1821        match result {
1822            Err(StoreError::CommittedAfterSave(inner)) => match *inner {
1823                StoreError::Conflict(msg) => msg,
1824                other => panic!("expected inner Conflict, got {other:?}"),
1825            },
1826            other => panic!("expected CommittedAfterSave, got {other:?}"),
1827        }
1828    }
1829
1830    /// Shared skeleton for the audit-invariant negative tests: serve a 200
1831    /// envelope mutated by `tweak`, run a mutation, and assert the
1832    /// `CommittedAfterSave`-wrapped violation message contains
1833    /// `expected_substr`.
1834    fn assert_audit_violation(tweak: impl FnOnce(&mut serde_json::Value), expected_substr: &str) {
1835        let body = wrap_mutation_tweaked(sample_env_domain(), tweak);
1836        let (_mock, store) = happy_store(200, &body);
1837        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
1838        let msg = unwrap_committed_conflict(result);
1839        assert!(
1840            msg.contains(expected_substr),
1841            "expected `{expected_substr}` violation, got: {msg}"
1842        );
1843    }
1844
1845    #[test]
1846    fn success_without_audit_is_rejected() {
1847        assert_audit_violation(
1848            |env| {
1849                env.as_object_mut().unwrap().remove("audit");
1850            },
1851            "missing the audit record",
1852        );
1853    }
1854
1855    #[test]
1856    fn success_with_deny_audit_is_rejected() {
1857        assert_audit_violation(
1858            |env| {
1859                env["audit"]["authorization"] = serde_json::json!({
1860                    "decision": "deny", "policy": "rbac-v1", "reason": "nope"
1861                });
1862            },
1863            "deny audit decision",
1864        );
1865    }
1866
1867    #[test]
1868    fn success_with_non_ok_audit_result_is_rejected() {
1869        assert_audit_violation(
1870            |env| {
1871                env["audit"]["result"] = serde_json::json!({
1872                    "outcome": "error", "kind": "store", "message": "boom"
1873                });
1874            },
1875            "non-ok audit result",
1876        );
1877    }
1878
1879    #[test]
1880    fn success_with_mismatched_audit_env_is_rejected() {
1881        assert_audit_violation(
1882            |env| {
1883                env["audit"]["env_id"] = serde_json::json!("other-env");
1884            },
1885            "names env `other-env`",
1886        );
1887    }
1888
1889    // FIX 1: audit idempotency key correlation tests
1890
1891    #[test]
1892    fn success_with_wrong_audit_idempotency_key_is_rejected() {
1893        assert_audit_violation(
1894            |env| {
1895                // Set audit key to a fixed wrong value (not the placeholder).
1896                env["audit"]["idempotency_key"] = serde_json::json!("01WRONG00000000000000000XX");
1897            },
1898            "does not belong",
1899        );
1900    }
1901
1902    #[test]
1903    fn success_with_missing_audit_idempotency_key_is_rejected() {
1904        assert_audit_violation(
1905            |env| {
1906                env["audit"]
1907                    .as_object_mut()
1908                    .unwrap()
1909                    .remove("idempotency_key");
1910            },
1911            "does not belong",
1912        );
1913    }
1914
1915    // FIX 2: post-2xx failures wrapped in CommittedAfterSave
1916
1917    #[test]
1918    fn success_2xx_with_garbage_body_maps_to_committed_after_save() {
1919        // A 200 response with a non-JSON body: the mutation may already be
1920        // committed, so the error must be CommittedAfterSave.
1921        let (_mock, store) = happy_store(200, "this is not json");
1922        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
1923        let msg = unwrap_committed_conflict(result);
1924        assert!(
1925            msg.contains("already be committed"),
1926            "expected committed guidance, got: {msg}"
1927        );
1928    }
1929
1930    // FIX 3: A8 status/kind agreement tests
1931
1932    #[test]
1933    fn error_500_with_unauthorized_body_is_contract_violation() {
1934        // HTTP 500 but the A8 body claims `unauthorized` (expects 403) — don't
1935        // trust either side.
1936        let err_body = serde_json::json!({
1937            "kind": "unauthorized",
1938            "policy": "rbac-v1",
1939            "reason": "nope"
1940        });
1941        let (_mock, store) = happy_store(500, &err_body.to_string());
1942        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
1943        match result {
1944            Err(StoreError::Conflict(msg)) => {
1945                assert!(
1946                    msg.contains("contradicts"),
1947                    "expected contract violation, got: {msg}"
1948                );
1949            }
1950            other => panic!("expected Conflict(contradicts...), got {other:?}"),
1951        }
1952    }
1953
1954    #[test]
1955    fn error_401_with_unauthorized_body_maps_to_unauthorized() {
1956        // HTTP 401 with an A8 `unauthorized`-kind body: the 401 allowance
1957        // means we trust the body.
1958        let err_body = serde_json::json!({
1959            "kind": "unauthorized",
1960            "policy": "rbac-v1",
1961            "reason": "expired token"
1962        });
1963        let (_mock, store) = happy_store(401, &err_body.to_string());
1964        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
1965        match result {
1966            Err(StoreError::Unauthorized { policy, reason }) => {
1967                assert_eq!(policy, "rbac-v1");
1968                assert_eq!(reason, "expired token");
1969            }
1970            other => panic!("expected Unauthorized, got {other:?}"),
1971        }
1972    }
1973
1974    // -----------------------------------------------------------------------
1975    // Environment lifecycle
1976    // -----------------------------------------------------------------------
1977
1978    #[test]
1979    fn create_environment_happy_path() {
1980        let domain = serde_json::json!({
1981            "schema": "greentic.environment.v1",
1982            "environment_id": "local",
1983            "name": "test",
1984            "host_config": {"env_id": "local"},
1985            "packs": [],
1986            "bundles": [],
1987            "revisions": [],
1988            "traffic_splits": [],
1989            "messaging_endpoints": [],
1990            "extensions": [],
1991            "revocation": {},
1992            "retention": {},
1993            "health": {}
1994        });
1995        let body = wrap_mutation(domain);
1996        let (_mock, store) = happy_store(201, &body);
1997        let result = store.create_environment(
1998            &env_id(),
1999            "test".to_string(),
2000            EnvironmentHostConfig {
2001                env_id: env_id(),
2002                region: None,
2003                tenant_org_id: None,
2004                listen_addr: None,
2005                public_base_url: None,
2006                gui_enabled: None,
2007            },
2008        );
2009        assert!(result.is_ok());
2010        assert_eq!(result.unwrap().name, "test");
2011    }
2012
2013    #[test]
2014    fn create_environment_conflict_returns_conflict() {
2015        let err_body = serde_json::json!({
2016            "kind": "idempotency-conflict",
2017            "reason": "environment already exists"
2018        });
2019        let (_mock, store) = happy_store(409, &err_body.to_string());
2020        let result = store.create_environment(
2021            &env_id(),
2022            "test".to_string(),
2023            EnvironmentHostConfig {
2024                env_id: env_id(),
2025                region: None,
2026                tenant_org_id: None,
2027                listen_addr: None,
2028                public_base_url: None,
2029                gui_enabled: None,
2030            },
2031        );
2032        assert!(matches!(result, Err(StoreError::Conflict(_))));
2033    }
2034
2035    #[test]
2036    fn update_environment_happy_path() {
2037        let domain = serde_json::json!({
2038            "schema": "greentic.environment.v1",
2039            "environment_id": "local",
2040            "name": "updated",
2041            "host_config": {"env_id": "local"},
2042            "packs": [],
2043            "bundles": [],
2044            "revisions": [],
2045            "traffic_splits": [],
2046            "messaging_endpoints": [],
2047            "extensions": [],
2048            "revocation": {},
2049            "retention": {},
2050            "health": {}
2051        });
2052        let body = wrap_mutation(domain);
2053        let (_mock, store) = happy_store(200, &body);
2054        let result = store.update_environment(
2055            &env_id(),
2056            UpdateEnvironmentPayload {
2057                name: Some("updated".to_string()),
2058                ..Default::default()
2059            },
2060        );
2061        assert!(result.is_ok());
2062        assert_eq!(result.unwrap().name, "updated");
2063    }
2064
2065    #[test]
2066    fn update_environment_not_found() {
2067        let err_body = serde_json::json!({"kind": "not-found"});
2068        let (_mock, store) = happy_store(404, &err_body.to_string());
2069        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
2070        assert!(matches!(result, Err(StoreError::NotFound(_))));
2071    }
2072
2073    // -----------------------------------------------------------------------
2074    // Migration
2075    // -----------------------------------------------------------------------
2076
2077    #[test]
2078    fn migrate_merge_bindings_happy_path() {
2079        let domain = serde_json::json!({
2080            "merged_slots": ["messaging"],
2081            "merged_extensions": ["capability/memory/long-term"]
2082        });
2083        let body = wrap_mutation(domain);
2084        let (_mock, store) = happy_store(200, &body);
2085        let result = store.migrate_merge_bindings(
2086            &env_id(),
2087            MigrateMergePayload {
2088                packs: Vec::new(),
2089                extensions: Vec::new(),
2090                seed_if_missing: None,
2091            },
2092        );
2093        assert!(result.is_ok());
2094        let (slots, exts) = result.unwrap();
2095        assert_eq!(slots, vec!["messaging"]);
2096        assert_eq!(exts, vec!["capability/memory/long-term"]);
2097    }
2098
2099    // -----------------------------------------------------------------------
2100    // Revision lifecycle
2101    // -----------------------------------------------------------------------
2102
2103    fn sample_revision_response() -> String {
2104        wrap_mutation(serde_json::json!({
2105            "schema": "greentic.revision.v1",
2106            "revision_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
2107            "env_id": "local",
2108            "bundle_id": "fast2flow",
2109            "deployment_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
2110            "sequence": 1,
2111            "created_at": "2026-06-09T12:00:00Z",
2112            "bundle_digest": "sha256:00",
2113            "pack_list": [],
2114            "pack_list_lock_ref": "",
2115            "pack_config_refs": [],
2116            "config_digest": "sha256:00",
2117            "signature_sidecar_ref": "rev.sig",
2118            "lifecycle": "staged",
2119            "staged_at": "2026-06-09T12:00:00Z",
2120            "drain_seconds": 30,
2121            "abort_metrics": []
2122        }))
2123    }
2124
2125    #[test]
2126    fn stage_revision_happy_path() {
2127        let body = sample_revision_response();
2128        let (_mock, store) = happy_store(201, &body);
2129        let result = store.stage_revision(
2130            &env_id(),
2131            StageRevisionPayload {
2132                revision_id: RevisionId::new(),
2133                deployment_id: DeploymentId::new(),
2134                bundle_digest: "sha256:00".to_string(),
2135                bundle_source_uri: None,
2136                pack_list: Vec::new(),
2137                pack_list_lock_ref: PathBuf::new(),
2138                pack_config_refs: Vec::new(),
2139                config_digest: "sha256:00".to_string(),
2140                signature_sidecar_ref: PathBuf::from("rev.sig"),
2141                drain_seconds: 30,
2142            },
2143            idem(),
2144        );
2145        assert!(result.is_ok());
2146    }
2147
2148    #[test]
2149    fn stage_revision_422_returns_invalid_argument() {
2150        let err_body = serde_json::json!({
2151            "kind": "integrity-mismatch",
2152            "expected": "abc",
2153            "actual": "def"
2154        });
2155        let (_mock, store) = happy_store(422, &err_body.to_string());
2156        let result = store.stage_revision(
2157            &env_id(),
2158            StageRevisionPayload {
2159                revision_id: RevisionId::new(),
2160                deployment_id: DeploymentId::new(),
2161                bundle_digest: "sha256:00".to_string(),
2162                bundle_source_uri: None,
2163                pack_list: Vec::new(),
2164                pack_list_lock_ref: PathBuf::new(),
2165                pack_config_refs: Vec::new(),
2166                config_digest: "sha256:00".to_string(),
2167                signature_sidecar_ref: PathBuf::from("rev.sig"),
2168                drain_seconds: 30,
2169            },
2170            idem(),
2171        );
2172        assert!(matches!(result, Err(StoreError::InvalidArgument(_))));
2173    }
2174
2175    fn sample_transition_response(lifecycle: &str) -> String {
2176        wrap_mutation(serde_json::json!({
2177            "revision": {
2178                "schema": "greentic.revision.v1",
2179                "revision_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
2180                "env_id": "local",
2181                "bundle_id": "fast2flow",
2182                "deployment_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
2183                "sequence": 1,
2184                "created_at": "2026-06-09T12:00:00Z",
2185                "bundle_digest": "sha256:00",
2186                "pack_list": [],
2187                "pack_list_lock_ref": "",
2188                "pack_config_refs": [],
2189                "config_digest": "sha256:00",
2190                "signature_sidecar_ref": "rev.sig",
2191                "lifecycle": lifecycle,
2192                "staged_at": "2026-06-09T12:00:00Z",
2193                "drain_seconds": 30,
2194                "abort_metrics": []
2195            },
2196            "environment": {
2197                "schema": "greentic.environment.v1",
2198                "environment_id": "local",
2199                "name": "test",
2200                "host_config": {"env_id": "local"},
2201                "packs": [],
2202                "bundles": [],
2203                "revisions": [],
2204                "traffic_splits": [],
2205                "messaging_endpoints": [],
2206                "extensions": [],
2207                "revocation": {},
2208                "retention": {},
2209                "health": {}
2210            },
2211            "starting_lifecycle": "staged"
2212        }))
2213    }
2214
2215    #[test]
2216    fn warm_revision_happy_path() {
2217        let body = sample_transition_response("ready");
2218        let (_mock, store) = happy_store(200, &body);
2219        let result = store.warm_revision(
2220            &env_id(),
2221            WarmRevisionPayload {
2222                revision_id: RevisionId::new(),
2223                health_gate: Ok(()),
2224                expected_lifecycle: RevisionLifecycle::Staged,
2225            },
2226            idem(),
2227        );
2228        assert!(result.is_ok());
2229        let outcome = result.unwrap();
2230        assert_eq!(outcome.revision.lifecycle, RevisionLifecycle::Ready);
2231        assert_eq!(outcome.starting_lifecycle, RevisionLifecycle::Staged);
2232    }
2233
2234    #[test]
2235    fn drain_revision_happy_path() {
2236        let body = sample_transition_response("draining");
2237        let (_mock, store) = happy_store(200, &body);
2238        let result = store.drain_revision(&env_id(), RevisionId::new(), idem());
2239        assert!(result.is_ok());
2240    }
2241
2242    #[test]
2243    fn archive_revision_happy_path() {
2244        let body = sample_transition_response("archived");
2245        let (_mock, store) = happy_store(200, &body);
2246        let result = store.archive_revision(&env_id(), RevisionId::new(), idem());
2247        assert!(result.is_ok());
2248    }
2249
2250    #[test]
2251    fn load_environment_happy_path() {
2252        // `GET /environments/{id}` → `GetEnvironmentResponse`: a plain read,
2253        // NOT a mutation envelope (no audit record on the wire).
2254        let body = serde_json::json!({
2255            "environment": {
2256                "schema": "greentic.environment.v1",
2257                "environment_id": "local",
2258                "name": "test",
2259                "host_config": {"env_id": "local"},
2260                "packs": [],
2261                "bundles": [],
2262                "revisions": [],
2263                "traffic_splits": [],
2264                "messaging_endpoints": [],
2265                "extensions": [],
2266                "revocation": {},
2267                "retention": {},
2268                "health": {}
2269            },
2270            "etag": "sha256:test",
2271            "generation": 3
2272        })
2273        .to_string();
2274        let (_mock, store) = happy_store(200, &body);
2275        let env = store.load_environment(&env_id()).expect("load ok");
2276        assert_eq!(env.environment_id.as_str(), "local");
2277        assert_eq!(env.name, "test");
2278    }
2279
2280    #[test]
2281    fn load_environment_404_returns_not_found() {
2282        let err_body = serde_json::json!({"kind": "not-found", "detail": "no such env"});
2283        let (_mock, store) = happy_store(404, &err_body.to_string());
2284        let result = store.load_environment(&env_id());
2285        assert!(matches!(result, Err(StoreError::NotFound(_))));
2286    }
2287
2288    // -----------------------------------------------------------------------
2289    // Bundle CRUD
2290    // -----------------------------------------------------------------------
2291
2292    fn sample_bundle_deployment() -> String {
2293        wrap_mutation(serde_json::json!({
2294            "schema": "greentic.bundle-deployment.v1",
2295            "deployment_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
2296            "env_id": "local",
2297            "bundle_id": "fast2flow",
2298            "customer_id": "local-dev",
2299            "status": "active",
2300            "current_revisions": [],
2301            "route_binding": {
2302                "hosts": ["fast2flow.local"],
2303                "path_prefixes": [],
2304                "tenant_selector": {"tenant": "default", "team": "default"}
2305            },
2306            "revenue_share": [{"party_id": "greentic", "basis_points": 10000}],
2307            "revenue_policy_ref": "revenue.json",
2308            "created_at": "2026-06-09T12:00:00Z",
2309            "authorization_ref": "auth.json",
2310            "config_overrides": {}
2311        }))
2312    }
2313
2314    #[test]
2315    fn add_bundle_happy_path() {
2316        let body = sample_bundle_deployment();
2317        let (_mock, store) = happy_store(201, &body);
2318        let result = store.add_bundle(
2319            &env_id(),
2320            AddBundlePayload {
2321                bundle_id: BundleId::new("fast2flow"),
2322                customer_id: greentic_deploy_spec::CustomerId::new("local-dev"),
2323                revenue_share: Vec::new(),
2324                route_binding: None,
2325                authorization_ref: None,
2326                config_overrides: std::collections::BTreeMap::new(),
2327            },
2328            idem(),
2329        );
2330        assert!(result.is_ok());
2331    }
2332
2333    #[test]
2334    fn update_bundle_happy_path() {
2335        let body = sample_bundle_deployment();
2336        let (_mock, store) = happy_store(200, &body);
2337        let result = store.update_bundle(
2338            &env_id(),
2339            UpdateBundlePayload {
2340                deployment_id: DeploymentId::new(),
2341                status: Some(greentic_deploy_spec::BundleDeploymentStatus::Active),
2342                route_binding: None,
2343                revenue_share: None,
2344                config_overrides: None,
2345            },
2346            idem(),
2347        );
2348        assert!(result.is_ok());
2349    }
2350
2351    #[test]
2352    fn remove_bundle_happy_path() {
2353        let domain = serde_json::json!({
2354            "deployment": {
2355                "schema": "greentic.bundle-deployment.v1",
2356                "deployment_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
2357                "env_id": "local",
2358                "bundle_id": "fast2flow",
2359                "customer_id": "local-dev",
2360                "status": "active",
2361                "current_revisions": [],
2362                "route_binding": {
2363                    "hosts": ["fast2flow.local"],
2364                    "path_prefixes": [],
2365                    "tenant_selector": {"tenant": "default", "team": "default"}
2366                },
2367                "revenue_share": [{"party_id": "greentic", "basis_points": 10000}],
2368                "revenue_policy_ref": "revenue.json",
2369                "created_at": "2026-06-09T12:00:00Z",
2370                "authorization_ref": "auth.json",
2371                "config_overrides": {}
2372            },
2373            "pruned_revision_ids": []
2374        });
2375        let body = wrap_mutation(domain);
2376        let (_mock, store) = happy_store(200, &body);
2377        let result = store.remove_bundle(&env_id(), DeploymentId::new(), idem());
2378        assert!(result.is_ok());
2379        assert!(result.unwrap().pruned_revision_ids.is_empty());
2380    }
2381
2382    // -----------------------------------------------------------------------
2383    // Pack binding CRUD
2384    // -----------------------------------------------------------------------
2385
2386    fn sample_pack_binding_json() -> String {
2387        serde_json::json!({
2388            "slot": "messaging",
2389            "kind": "greentic.messaging@0.5.0",
2390            "pack_ref": "greentic-messaging",
2391            "generation": 1
2392        })
2393        .to_string()
2394    }
2395
2396    fn sample_pack_binding() -> String {
2397        wrap_mutation(serde_json::from_str(&sample_pack_binding_json()).unwrap())
2398    }
2399
2400    fn sample_binding_generation_response(binding_json: &str, generation: u64) -> String {
2401        let domain: serde_json::Value = serde_json::from_str(&format!(
2402            r#"{{"binding": {binding_json}, "generation": {generation}}}"#
2403        ))
2404        .unwrap();
2405        wrap_mutation(domain)
2406    }
2407
2408    #[test]
2409    fn add_pack_binding_happy_path() {
2410        let body = sample_pack_binding();
2411        let (_mock, store) = happy_store(201, &body);
2412        let binding: EnvPackBinding = serde_json::from_str(&sample_pack_binding_json()).unwrap();
2413        let result = store.add_pack_binding(&env_id(), binding, idem());
2414        assert!(result.is_ok());
2415    }
2416
2417    #[test]
2418    fn update_pack_binding_happy_path() {
2419        let binding_json = sample_pack_binding_json();
2420        let body = sample_binding_generation_response(&binding_json, 2);
2421        let (_mock, store) = happy_store(200, &body);
2422        let binding: EnvPackBinding = serde_json::from_str(&binding_json).unwrap();
2423        let result =
2424            store.update_pack_binding(&env_id(), CapabilitySlot::Messaging, binding, idem());
2425        assert!(result.is_ok());
2426        let (_, generation) = result.unwrap();
2427        assert_eq!(generation, 2);
2428    }
2429
2430    #[test]
2431    fn remove_pack_binding_happy_path() {
2432        let binding_json = sample_pack_binding_json();
2433        let body = sample_binding_generation_response(&binding_json, 3);
2434        let (_mock, store) = happy_store(200, &body);
2435        let result = store.remove_pack_binding(&env_id(), CapabilitySlot::Messaging, idem());
2436        assert!(result.is_ok());
2437    }
2438
2439    #[test]
2440    fn rollback_pack_binding_happy_path() {
2441        let binding_json = sample_pack_binding_json();
2442        let body = sample_binding_generation_response(&binding_json, 4);
2443        let (_mock, store) = happy_store(200, &body);
2444        let result = store.rollback_pack_binding(&env_id(), CapabilitySlot::Messaging, idem());
2445        assert!(result.is_ok());
2446    }
2447
2448    // -----------------------------------------------------------------------
2449    // Extension binding CRUD
2450    // -----------------------------------------------------------------------
2451
2452    fn sample_extension_binding_json() -> String {
2453        serde_json::json!({
2454            "kind": "greentic.memory-chronicle@0.1.0",
2455            "pack_ref": "greentic-chronicle",
2456            "generation": 1
2457        })
2458        .to_string()
2459    }
2460
2461    fn sample_extension_binding() -> String {
2462        wrap_mutation(serde_json::from_str(&sample_extension_binding_json()).unwrap())
2463    }
2464
2465    #[test]
2466    fn add_extension_binding_happy_path() {
2467        let body = sample_extension_binding();
2468        let (_mock, store) = happy_store(201, &body);
2469        let binding: ExtensionBinding =
2470            serde_json::from_str(&sample_extension_binding_json()).unwrap();
2471        let result = store.add_extension_binding(&env_id(), binding, idem());
2472        assert!(result.is_ok());
2473    }
2474
2475    #[test]
2476    fn update_extension_binding_happy_path() {
2477        let ext_json = sample_extension_binding_json();
2478        let body = sample_binding_generation_response(&ext_json, 2);
2479        let (_mock, store) = happy_store(200, &body);
2480        let binding: ExtensionBinding = serde_json::from_str(&ext_json).unwrap();
2481        let key = ExtensionKey::new("capability/memory/long-term", None);
2482        let result = store.update_extension_binding(&env_id(), key, binding, idem());
2483        assert!(result.is_ok());
2484        let (_, generation) = result.unwrap();
2485        assert_eq!(generation, 2);
2486    }
2487
2488    #[test]
2489    fn remove_extension_binding_happy_path() {
2490        let ext_json = sample_extension_binding_json();
2491        let body = sample_binding_generation_response(&ext_json, 3);
2492        let (_mock, store) = happy_store(200, &body);
2493        let key = ExtensionKey::new("capability/memory/long-term", None);
2494        let result = store.remove_extension_binding(&env_id(), key, idem());
2495        assert!(result.is_ok());
2496    }
2497
2498    #[test]
2499    fn rollback_extension_binding_happy_path() {
2500        let ext_json = sample_extension_binding_json();
2501        let body = sample_binding_generation_response(&ext_json, 4);
2502        let (_mock, store) = happy_store(200, &body);
2503        let key = ExtensionKey::new("capability/memory/long-term", None);
2504        let result = store.rollback_extension_binding(&env_id(), key, idem());
2505        assert!(result.is_ok());
2506    }
2507
2508    // -----------------------------------------------------------------------
2509    // Traffic
2510    // -----------------------------------------------------------------------
2511
2512    fn sample_traffic_split() -> serde_json::Value {
2513        serde_json::json!({
2514            "schema": "greentic.traffic-split.v1",
2515            "env_id": "local",
2516            "deployment_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
2517            "bundle_id": "fast2flow",
2518            "generation": 2,
2519            "entries": [],
2520            "updated_at": "2026-06-09T12:00:00Z",
2521            "updated_by": "tester",
2522            "idempotency_key": "01JABC000000000000000000ZZ",
2523            "authorization_ref": "auth.json"
2524        })
2525    }
2526
2527    #[test]
2528    fn set_traffic_split_happy_path() {
2529        let domain = serde_json::json!({
2530            "split": sample_traffic_split(),
2531            "previous_generation": 1,
2532            "new_generation": 2,
2533            "environment": sample_env_domain()
2534        });
2535        let body = wrap_mutation(domain);
2536        let (_mock, store) = happy_store(200, &body);
2537        let result = store.set_traffic_split(
2538            &env_id(),
2539            SetTrafficSplitPayload {
2540                deployment_id: DeploymentId::new(),
2541                entries: Vec::new(),
2542                updated_by: "tester".to_string(),
2543                authorization_ref: None,
2544            },
2545            idem(),
2546        );
2547        assert!(result.is_ok());
2548        let outcome = result.unwrap();
2549        assert_eq!(outcome.previous_generation, Some(1));
2550        assert_eq!(outcome.new_generation, Some(2));
2551        assert_eq!(outcome.environment.environment_id, env_id());
2552    }
2553
2554    #[test]
2555    fn rollback_traffic_split_happy_path() {
2556        let domain = serde_json::json!({
2557            "restored": sample_traffic_split(),
2558            "previous_generation": 2,
2559            "new_generation": 3,
2560            "environment": sample_env_domain()
2561        });
2562        let body = wrap_mutation(domain);
2563        let (_mock, store) = happy_store(200, &body);
2564        let result = store.rollback_traffic_split(&env_id(), DeploymentId::new(), idem());
2565        assert!(result.is_ok());
2566    }
2567
2568    // -----------------------------------------------------------------------
2569    // Messaging endpoints
2570    // -----------------------------------------------------------------------
2571
2572    fn sample_messaging_endpoint() -> String {
2573        wrap_mutation(serde_json::json!({
2574            "schema": "greentic.messaging-endpoint.v1",
2575            "env_id": "local",
2576            "endpoint_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
2577            "provider_id": "tg-bot",
2578            "provider_type": "telegram",
2579            "display_name": "Telegram Bot",
2580            "secret_refs": [],
2581            "linked_bundles": [],
2582            "generation": 0,
2583            "created_at": "2026-06-09T12:00:00Z",
2584            "updated_at": "2026-06-09T12:00:00Z",
2585            "updated_by": "tester"
2586        }))
2587    }
2588
2589    #[test]
2590    fn add_messaging_endpoint_happy_path() {
2591        let body = sample_messaging_endpoint();
2592        let (_mock, store) = happy_store(201, &body);
2593        let result = store.add_messaging_endpoint(
2594            &env_id(),
2595            AddMessagingEndpointPayload {
2596                provider_id: "tg-bot".to_string(),
2597                provider_type: "telegram".to_string(),
2598                display_name: "Telegram Bot".to_string(),
2599                secret_refs: Vec::new(),
2600                webhook_secret_ref: Some(
2601                    "secret://local/default/_/messaging-byo/webhook_secret".to_string(),
2602                ),
2603                updated_by: "tester".to_string(),
2604            },
2605            idem(),
2606        );
2607        assert!(result.is_ok());
2608    }
2609
2610    #[test]
2611    fn link_messaging_bundle_happy_path() {
2612        let body = sample_messaging_endpoint();
2613        let (_mock, store) = happy_store(200, &body);
2614        let result = store.link_messaging_bundle(
2615            &env_id(),
2616            MessagingEndpointId::new(),
2617            BundleId::new("fast2flow"),
2618            "tester".to_string(),
2619            idem(),
2620        );
2621        assert!(result.is_ok());
2622    }
2623
2624    #[test]
2625    fn unlink_messaging_bundle_happy_path() {
2626        let body = sample_messaging_endpoint();
2627        let (_mock, store) = happy_store(200, &body);
2628        let result = store.unlink_messaging_bundle(
2629            &env_id(),
2630            MessagingEndpointId::new(),
2631            BundleId::new("fast2flow"),
2632            "tester".to_string(),
2633            idem(),
2634        );
2635        assert!(result.is_ok());
2636    }
2637
2638    #[test]
2639    fn set_messaging_welcome_flow_happy_path() {
2640        let body = sample_messaging_endpoint();
2641        let (_mock, store) = happy_store(200, &body);
2642        let result = store.set_messaging_welcome_flow(
2643            &env_id(),
2644            SetMessagingWelcomeFlowPayload {
2645                endpoint_id: MessagingEndpointId::new(),
2646                bundle_id: BundleId::new("fast2flow"),
2647                pack_id: PackId::new("greentic-messaging"),
2648                flow_id: "welcome".to_string(),
2649                updated_by: "tester".to_string(),
2650            },
2651            idem(),
2652        );
2653        assert!(result.is_ok());
2654    }
2655
2656    #[test]
2657    fn remove_messaging_endpoint_happy_path() {
2658        let eid = MessagingEndpointId::new();
2659        let body = wrap_mutation(serde_json::json!(eid.to_string()));
2660        let (_mock, store) = happy_store(200, &body);
2661        let result = store.remove_messaging_endpoint(&env_id(), eid);
2662        assert!(result.is_ok());
2663    }
2664
2665    #[test]
2666    fn rotate_messaging_webhook_secret_happy_path() {
2667        let body = sample_messaging_endpoint();
2668        let (_mock, store) = happy_store(200, &body);
2669        let result = store.rotate_messaging_webhook_secret(
2670            &env_id(),
2671            MessagingEndpointId::new(),
2672            "tester".to_string(),
2673            idem(),
2674        );
2675        assert!(result.is_ok());
2676    }
2677
2678    #[test]
2679    fn rotate_messaging_webhook_secret_to_ref_carries_new_ref() {
2680        // The new-ref variant: the inherent method threads a caller-supplied
2681        // ref into the request body. The server records it (proven server-side
2682        // in http_api); here we assert the client round-trips a 200 response.
2683        let body = sample_messaging_endpoint();
2684        let (_mock, store) = happy_store(200, &body);
2685        let result = store.rotate_messaging_webhook_secret_to_ref(
2686            &env_id(),
2687            MessagingEndpointId::new(),
2688            "tester".to_string(),
2689            Some("secret://local/acme/_/messaging-tg/webhook_secret".to_string()),
2690            idem(),
2691        );
2692        assert!(result.is_ok());
2693    }
2694
2695    // -----------------------------------------------------------------------
2696    // Trust root
2697    // -----------------------------------------------------------------------
2698
2699    fn sample_trust_root_seed() -> String {
2700        wrap_mutation(serde_json::json!({
2701            "key_id": "op-key-1",
2702            "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMFkw...\n-----END PUBLIC KEY-----",
2703            "trusted_key_count": 1
2704        }))
2705    }
2706
2707    #[test]
2708    fn bootstrap_trust_root_happy_path() {
2709        let body = sample_trust_root_seed();
2710        let (_mock, store) = happy_store(201, &body);
2711        let result = store.bootstrap_trust_root(&env_id());
2712        assert!(result.is_ok());
2713        let seed = result.unwrap();
2714        assert_eq!(seed.key_id, "op-key-1");
2715        assert_eq!(seed.trusted_key_count, 1);
2716    }
2717
2718    #[test]
2719    fn seed_trust_root_if_absent_when_seeded() {
2720        let body = sample_trust_root_seed();
2721        let (_mock, store) = happy_store(200, &body);
2722        let result = store.seed_trust_root_if_absent(&env_id());
2723        assert!(result.is_ok());
2724        assert!(result.unwrap().is_some());
2725    }
2726
2727    #[test]
2728    fn seed_trust_root_if_absent_when_already_exists() {
2729        let body = wrap_mutation(serde_json::Value::Null);
2730        let (_mock, store) = happy_store(200, &body);
2731        let result = store.seed_trust_root_if_absent(&env_id());
2732        assert!(result.is_ok());
2733        assert!(result.unwrap().is_none());
2734    }
2735
2736    #[test]
2737    fn add_trusted_key_happy_path() {
2738        let domain = serde_json::json!({
2739            "added_key_id": "external-key-1",
2740            "trusted_key_count": 2
2741        });
2742        let body = wrap_mutation(domain);
2743        let (_mock, store) = happy_store(201, &body);
2744        let result = store.add_trusted_key(
2745            &env_id(),
2746            "external-key-1".to_string(),
2747            "PEM-DATA".to_string(),
2748            idem(),
2749        );
2750        assert!(result.is_ok());
2751        let outcome = result.unwrap();
2752        assert_eq!(outcome.added_key_id, "external-key-1");
2753        assert_eq!(outcome.trusted_key_count, 2);
2754    }
2755
2756    #[test]
2757    fn remove_trusted_key_happy_path() {
2758        let domain = serde_json::json!({
2759            "removed_key_id": "external-key-1",
2760            "removed_public_key_pem": "PEM-DATA",
2761            "trusted_key_count": 1
2762        });
2763        let body = wrap_mutation(domain);
2764        let (_mock, store) = happy_store(200, &body);
2765        let result = store.remove_trusted_key(&env_id(), "external-key-1".to_string(), idem());
2766        assert!(result.is_ok());
2767        let outcome = result.unwrap();
2768        assert_eq!(outcome.removed_key_id, "external-key-1");
2769        assert_eq!(outcome.removed_public_key_pem, Some("PEM-DATA".to_string()));
2770    }
2771
2772    // -----------------------------------------------------------------------
2773    // Auth + header tests
2774    // -----------------------------------------------------------------------
2775
2776    #[test]
2777    fn bearer_auth_sends_authorization_header() {
2778        let body = sample_trust_root_seed();
2779        let check = Arc::new(|_req_line: &str, headers: &str, _body: &[u8]| {
2780            assert!(
2781                headers.contains("Authorization: Bearer my-secret-token"),
2782                "expected Bearer header in: {headers}"
2783            );
2784        });
2785        let mock = start_mock(vec![(200, &body)], Some(check));
2786        let store = mock_store(mock.addr, AuthMethod::Bearer("my-secret-token".to_string()));
2787        let _ = store.bootstrap_trust_root(&env_id());
2788    }
2789
2790    #[test]
2791    fn idempotency_key_header_is_sent() {
2792        let body = sample_revision_response();
2793        let check = Arc::new(|_req_line: &str, headers: &str, _body: &[u8]| {
2794            assert!(
2795                headers.contains("Idempotency-Key: 01JABC000000000000000000ZZ"),
2796                "expected Idempotency-Key header in: {headers}"
2797            );
2798        });
2799        let mock = start_mock(vec![(201, &body)], Some(check));
2800        let store = mock_store(mock.addr, AuthMethod::None);
2801        let _ = store.stage_revision(
2802            &env_id(),
2803            StageRevisionPayload {
2804                revision_id: RevisionId::new(),
2805                deployment_id: DeploymentId::new(),
2806                bundle_digest: "sha256:00".to_string(),
2807                bundle_source_uri: None,
2808                pack_list: Vec::new(),
2809                pack_list_lock_ref: PathBuf::new(),
2810                pack_config_refs: Vec::new(),
2811                config_digest: "sha256:00".to_string(),
2812                signature_sidecar_ref: PathBuf::from("rev.sig"),
2813                drain_seconds: 30,
2814            },
2815            idem(),
2816        );
2817    }
2818
2819    // -----------------------------------------------------------------------
2820    // ETag-chained CAS (wire-layer If-Match)
2821    // -----------------------------------------------------------------------
2822
2823    /// A [`CheckFn`] that records each request's (lower-cased) header block in
2824    /// arrival order, so a test can assert per-request `If-Match` behaviour
2825    /// after the calls complete.
2826    fn header_recorder() -> (Arc<Mutex<Vec<String>>>, CheckFn) {
2827        let recorded: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2828        let sink = recorded.clone();
2829        let check: CheckFn = Arc::new(move |_req: &str, headers: &str, _body: &[u8]| {
2830            sink.lock().unwrap().push(headers.to_lowercase());
2831        });
2832        (recorded, check)
2833    }
2834
2835    /// A mutation envelope (`update_environment` shape) carrying a chosen ETag.
2836    fn env_envelope_with_etag(etag: &str) -> String {
2837        wrap_mutation_tweaked(sample_env_domain(), |env| {
2838            env["etag"] = serde_json::json!(etag);
2839        })
2840    }
2841
2842    /// The `load_environment` GET body (plain read, not a mutation envelope).
2843    fn get_env_body_with_etag(etag: &str) -> String {
2844        serde_json::json!({
2845            "environment": sample_env_domain(),
2846            "etag": etag,
2847            "generation": 1
2848        })
2849        .to_string()
2850    }
2851
2852    #[test]
2853    fn first_mutation_sends_no_if_match_then_chains_next() {
2854        // Fresh store has no cached ETag → first write omits If-Match; its
2855        // response ETag is then replayed as If-Match on the second write.
2856        let (recorded, check) = header_recorder();
2857        let mock = start_mock(
2858            vec![
2859                (200, &env_envelope_with_etag("sha256:rev1")),
2860                (200, &env_envelope_with_etag("sha256:rev2")),
2861            ],
2862            Some(check),
2863        );
2864        let store = mock_store(mock.addr, AuthMethod::None);
2865        store
2866            .update_environment(&env_id(), UpdateEnvironmentPayload::default())
2867            .expect("first write ok");
2868        store
2869            .update_environment(&env_id(), UpdateEnvironmentPayload::default())
2870            .expect("second write ok");
2871
2872        let headers = recorded.lock().unwrap();
2873        assert_eq!(headers.len(), 2, "expected two requests");
2874        assert!(
2875            !headers[0].contains("if-match:"),
2876            "first write must not send If-Match: {}",
2877            headers[0]
2878        );
2879        assert!(
2880            headers[1].contains("if-match: \"sha256:rev1\""),
2881            "second write must replay the first response's ETag: {}",
2882            headers[1]
2883        );
2884    }
2885
2886    #[test]
2887    fn read_then_write_pins_the_read_etag() {
2888        // `load_environment` captures the GET ETag; the following mutation
2889        // (the read-then-decide-then-write shape, e.g. `warm`) pins it.
2890        let (recorded, check) = header_recorder();
2891        let mock = start_mock(
2892            vec![
2893                (200, &get_env_body_with_etag("sha256:loaded")),
2894                (200, &env_envelope_with_etag("sha256:rev2")),
2895            ],
2896            Some(check),
2897        );
2898        let store = mock_store(mock.addr, AuthMethod::None);
2899        store.load_environment(&env_id()).expect("load ok");
2900        store
2901            .update_environment(&env_id(), UpdateEnvironmentPayload::default())
2902            .expect("write ok");
2903
2904        let headers = recorded.lock().unwrap();
2905        assert!(
2906            !headers[0].contains("if-match:"),
2907            "the GET read must not send If-Match: {}",
2908            headers[0]
2909        );
2910        assert!(
2911            headers[1].contains("if-match: \"sha256:loaded\""),
2912            "the write must pin the loaded ETag: {}",
2913            headers[1]
2914        );
2915    }
2916
2917    #[test]
2918    fn precondition_failed_surfaces_conflict_and_clears_cache() {
2919        // write1 caches an ETag; write2 sends it and gets 412 → Conflict +
2920        // cache cleared; write3 therefore sends no If-Match.
2921        let (recorded, check) = header_recorder();
2922        let mock = start_mock(
2923            vec![
2924                (200, &env_envelope_with_etag("sha256:rev1")),
2925                (412, r#"{"detail":"stale"}"#),
2926                (200, &env_envelope_with_etag("sha256:rev3")),
2927            ],
2928            Some(check),
2929        );
2930        let store = mock_store(mock.addr, AuthMethod::None);
2931        store
2932            .update_environment(&env_id(), UpdateEnvironmentPayload::default())
2933            .expect("write1 ok");
2934        let conflict = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
2935        assert!(
2936            matches!(conflict, Err(StoreError::Conflict(_))),
2937            "412 must surface as Conflict, got {conflict:?}"
2938        );
2939        store
2940            .update_environment(&env_id(), UpdateEnvironmentPayload::default())
2941            .expect("write3 ok");
2942
2943        let headers = recorded.lock().unwrap();
2944        assert_eq!(headers.len(), 3, "expected three requests");
2945        assert!(
2946            !headers[0].contains("if-match:"),
2947            "write1 must not send If-Match: {}",
2948            headers[0]
2949        );
2950        assert!(
2951            headers[1].contains("if-match: \"sha256:rev1\""),
2952            "write2 must replay write1's ETag: {}",
2953            headers[1]
2954        );
2955        assert!(
2956            !headers[2].contains("if-match:"),
2957            "write3 must not replay a 412-invalidated ETag: {}",
2958            headers[2]
2959        );
2960    }
2961
2962    #[test]
2963    fn etag_cache_is_scoped_per_environment() {
2964        // One store represents an endpoint, not an env: a read of env A must
2965        // not pin a write to env B. Load `staging`, then mutate `local` — the
2966        // mutation must send no If-Match (the cache holds only `staging`).
2967        let staging = EnvId::try_from("staging").unwrap();
2968        let (recorded, check) = header_recorder();
2969        let mock = start_mock(
2970            vec![
2971                (200, &get_env_body_with_etag("sha256:staging")),
2972                (200, &env_envelope_with_etag("sha256:local")),
2973            ],
2974            Some(check),
2975        );
2976        let store = mock_store(mock.addr, AuthMethod::None);
2977        store.load_environment(&staging).expect("load staging ok");
2978        store
2979            .update_environment(&env_id(), UpdateEnvironmentPayload::default())
2980            .expect("write local ok");
2981
2982        let headers = recorded.lock().unwrap();
2983        assert!(
2984            !headers[1].contains("if-match:"),
2985            "a write to `local` must not replay `staging`'s ETag: {}",
2986            headers[1]
2987        );
2988    }
2989
2990    #[test]
2991    fn mutation_response_without_etag_is_rejected() {
2992        // A8 makes `etag` non-optional on a mutation response; CAS depends on
2993        // it, so a 2xx envelope that omits it is a contract violation, not a
2994        // silent fall-back to blind writes.
2995        let body = wrap_mutation_tweaked(sample_env_domain(), |env| {
2996            env.as_object_mut().unwrap().remove("etag");
2997        });
2998        let (_mock, store) = happy_store(200, &body);
2999        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
3000        let msg = unwrap_committed_conflict(result);
3001        assert!(
3002            msg.contains("etag"),
3003            "expected a missing-etag contract violation, got: {msg}"
3004        );
3005    }
3006
3007    // -----------------------------------------------------------------------
3008    // Error mapping tests
3009    // -----------------------------------------------------------------------
3010
3011    #[test]
3012    fn error_404_maps_to_not_found() {
3013        let err_body = serde_json::json!({"kind": "not-found"});
3014        let (_mock, store) = happy_store(404, &err_body.to_string());
3015        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
3016        assert!(matches!(result, Err(StoreError::NotFound(_))));
3017    }
3018
3019    #[test]
3020    fn error_409_maps_to_conflict() {
3021        let err_body = serde_json::json!({
3022            "kind": "idempotency-conflict",
3023            "reason": "key reused"
3024        });
3025        let (_mock, store) = happy_store(409, &err_body.to_string());
3026        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
3027        assert!(matches!(result, Err(StoreError::Conflict(_))));
3028    }
3029
3030    #[test]
3031    fn error_500_maps_to_conflict_server() {
3032        let err_body = serde_json::json!({
3033            "kind": "internal",
3034            "message": "disk full"
3035        });
3036        let (_mock, store) = happy_store(500, &err_body.to_string());
3037        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
3038        match result {
3039            Err(StoreError::Conflict(msg)) => {
3040                assert!(
3041                    msg.contains("server:"),
3042                    "expected 'server:' prefix, got: {msg}"
3043                );
3044            }
3045            other => panic!("expected Conflict, got {other:?}"),
3046        }
3047    }
3048
3049    #[test]
3050    fn error_403_maps_to_unauthorized() {
3051        // PR-4.0 (F4): an A8 `unauthorized` body keeps its typed noun —
3052        // previously it was flattened into `StoreError::Conflict`, so RBAC
3053        // denials rendered as `error.kind: conflict` in the CLI envelope.
3054        let err_body = serde_json::json!({
3055            "kind": "unauthorized",
3056            "policy": "rbac-v1",
3057            "reason": "insufficient permissions"
3058        });
3059        let (_mock, store) = happy_store(403, &err_body.to_string());
3060        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
3061        match result {
3062            Err(StoreError::Unauthorized { policy, reason }) => {
3063                assert_eq!(policy, "rbac-v1");
3064                assert_eq!(reason, "insufficient permissions");
3065            }
3066            other => panic!("expected Unauthorized, got {other:?}"),
3067        }
3068    }
3069
3070    #[test]
3071    fn error_403_without_a8_body_maps_to_unauthorized() {
3072        // A denial from a proxy/LB in front of the store (non-A8 body) is
3073        // still a denial — the fallback status mapping keeps the noun.
3074        let (_mock, store) = happy_store(403, "access denied by gateway");
3075        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
3076        match result {
3077            Err(StoreError::Unauthorized { policy, reason }) => {
3078                assert_eq!(policy, "remote");
3079                assert_eq!(reason, "access denied by gateway");
3080            }
3081            other => panic!("expected Unauthorized, got {other:?}"),
3082        }
3083    }
3084
3085    #[test]
3086    fn error_501_maps_to_not_yet_implemented() {
3087        let err_body = serde_json::json!({
3088            "kind": "not-yet-implemented",
3089            "detail": "backup/restore lands in PR-4"
3090        });
3091        let (_mock, store) = happy_store(501, &err_body.to_string());
3092        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
3093        match result {
3094            Err(StoreError::NotYetImplemented(detail)) => {
3095                assert_eq!(detail, "backup/restore lands in PR-4");
3096            }
3097            other => panic!("expected NotYetImplemented, got {other:?}"),
3098        }
3099    }
3100
3101    #[test]
3102    fn transport_error_maps_to_conflict() {
3103        // Connect to a port that is definitely not listening.
3104        let store =
3105            HttpEnvironmentStore::new(Url::parse("http://127.0.0.1:1").unwrap(), AuthMethod::None)
3106                .unwrap();
3107        let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
3108        match result {
3109            Err(StoreError::Conflict(msg)) => {
3110                assert!(
3111                    msg.starts_with("transport:"),
3112                    "expected 'transport:' prefix, got: {msg}"
3113                );
3114            }
3115            other => panic!("expected Conflict(transport:...), got {other:?}"),
3116        }
3117    }
3118
3119    // -----------------------------------------------------------------------
3120    // Fix 1: percent-encoding of dynamic path segments
3121    // -----------------------------------------------------------------------
3122
3123    #[test]
3124    fn encode_segment_passes_through_alphanumeric() {
3125        assert_eq!(encode_segment("local"), "local");
3126        assert_eq!(
3127            encode_segment("01JTKW5B4W4Q5Y1CQW93F7S5VH"),
3128            "01JTKW5B4W4Q5Y1CQW93F7S5VH"
3129        );
3130    }
3131
3132    #[test]
3133    fn encode_segment_escapes_slash() {
3134        let encoded = encode_segment("foo/bar");
3135        assert!(
3136            !encoded.contains('/'),
3137            "slash must be escaped, got: {encoded}"
3138        );
3139        assert!(encoded.contains("%2F"));
3140    }
3141
3142    #[test]
3143    fn encode_segment_escapes_dot_dot() {
3144        let encoded = encode_segment("..");
3145        assert!(
3146            !encoded.contains(".."),
3147            "dots must be escaped, got: {encoded}"
3148        );
3149        assert!(encoded.contains("%2E"));
3150    }
3151
3152    #[test]
3153    fn encode_segment_escapes_query() {
3154        let encoded = encode_segment("id?foo=bar");
3155        assert!(
3156            !encoded.contains('?'),
3157            "query marker must be escaped, got: {encoded}"
3158        );
3159    }
3160
3161    #[test]
3162    fn encode_segment_escapes_fragment() {
3163        let encoded = encode_segment("id#frag");
3164        assert!(
3165            !encoded.contains('#'),
3166            "fragment marker must be escaped, got: {encoded}"
3167        );
3168    }
3169
3170    #[test]
3171    fn key_id_with_slash_reaches_escaped_path() {
3172        let body = wrap_mutation(serde_json::json!({
3173            "removed_key_id": "a/b",
3174            "removed_public_key_pem": "PEM",
3175            "trusted_key_count": 0
3176        }));
3177        let check = Arc::new(|req_line: &str, _headers: &str, _body: &[u8]| {
3178            assert!(
3179                req_line.contains("a%2Fb"),
3180                "expected escaped slash in path, got: {req_line}"
3181            );
3182        });
3183        let mock = start_mock(vec![(200, &body)], Some(check));
3184        let store = mock_store(mock.addr, AuthMethod::None);
3185        let _ = store.remove_trusted_key(&env_id(), "a/b".to_string(), idem());
3186    }
3187
3188    // -----------------------------------------------------------------------
3189    // Fix 2: MutationEnvelope parsing
3190    // -----------------------------------------------------------------------
3191
3192    #[test]
3193    fn mutation_envelope_with_extra_metadata_is_accepted() {
3194        // The envelope carries etag/generation/audit alongside the result.
3195        // Verify that the client parses the envelope correctly.
3196        let domain = serde_json::json!({
3197            "schema": "greentic.environment.v1",
3198            "environment_id": "local",
3199            "name": "test-envelope",
3200            "host_config": {"env_id": "local"},
3201            "packs": [],
3202            "bundles": [],
3203            "revisions": [],
3204            "traffic_splits": [],
3205            "messaging_endpoints": [],
3206            "extensions": [],
3207            "revocation": {},
3208            "retention": {},
3209            "health": {}
3210        });
3211        let body = wrap_mutation(domain);
3212        let (_mock, store) = happy_store(201, &body);
3213        let result = store.create_environment(
3214            &env_id(),
3215            "test-envelope".to_string(),
3216            EnvironmentHostConfig {
3217                env_id: env_id(),
3218                region: None,
3219                tenant_org_id: None,
3220                listen_addr: None,
3221                public_base_url: None,
3222                gui_enabled: None,
3223            },
3224        );
3225        assert!(result.is_ok());
3226        assert_eq!(result.unwrap().name, "test-envelope");
3227    }
3228
3229    // -----------------------------------------------------------------------
3230    // Fix 4: idempotency-key minting for methods without payload key
3231    // -----------------------------------------------------------------------
3232
3233    #[test]
3234    fn bootstrap_trust_root_sends_idempotency_key() {
3235        let body = sample_trust_root_seed();
3236        let check = Arc::new(|_req_line: &str, headers: &str, _body: &[u8]| {
3237            assert!(
3238                headers.contains("Idempotency-Key:"),
3239                "expected Idempotency-Key header in: {headers}"
3240            );
3241            // Extract the key value and verify it's non-empty.
3242            let key = headers
3243                .lines()
3244                .find(|l| l.starts_with("Idempotency-Key:"))
3245                .and_then(|l| l.split(':').nth(1))
3246                .map(|v| v.trim())
3247                .unwrap_or("");
3248            assert!(!key.is_empty(), "Idempotency-Key must be non-empty");
3249        });
3250        let mock = start_mock(vec![(201, &body)], Some(check));
3251        let store = mock_store(mock.addr, AuthMethod::None);
3252        let _ = store.bootstrap_trust_root(&env_id());
3253    }
3254
3255    #[test]
3256    fn create_environment_sends_idempotency_key() {
3257        let domain = serde_json::json!({
3258            "schema": "greentic.environment.v1",
3259            "environment_id": "local",
3260            "name": "test",
3261            "host_config": {"env_id": "local"},
3262            "packs": [],
3263            "bundles": [],
3264            "revisions": [],
3265            "traffic_splits": [],
3266            "messaging_endpoints": [],
3267            "extensions": [],
3268            "revocation": {},
3269            "retention": {},
3270            "health": {}
3271        });
3272        let body = wrap_mutation(domain);
3273        let check = Arc::new(|_req_line: &str, headers: &str, _body: &[u8]| {
3274            assert!(
3275                headers.contains("Idempotency-Key:"),
3276                "expected Idempotency-Key header for create_environment: {headers}"
3277            );
3278        });
3279        let mock = start_mock(vec![(201, &body)], Some(check));
3280        let store = mock_store(mock.addr, AuthMethod::None);
3281        let _ = store.create_environment(
3282            &env_id(),
3283            "test".to_string(),
3284            EnvironmentHostConfig {
3285                env_id: env_id(),
3286                region: None,
3287                tenant_org_id: None,
3288                listen_addr: None,
3289                public_base_url: None,
3290                gui_enabled: None,
3291            },
3292        );
3293    }
3294
3295    #[test]
3296    fn minted_keys_are_unique_per_call() {
3297        let key1 = mint_idempotency_key();
3298        let key2 = mint_idempotency_key();
3299        assert_ne!(key1, key2, "minted keys must differ per call");
3300    }
3301
3302    // -----------------------------------------------------------------------
3303    // Fix 5: insecure-transport guard
3304    // -----------------------------------------------------------------------
3305
3306    #[test]
3307    fn bearer_over_http_non_loopback_is_rejected() {
3308        let result = HttpEnvironmentStore::new(
3309            Url::parse("http://192.0.2.1:8080").unwrap(),
3310            AuthMethod::Bearer("token".to_string()),
3311        );
3312        assert!(result.is_err());
3313        let err = result.unwrap_err();
3314        assert!(
3315            matches!(err, ConstructionError::InsecureTransport(_)),
3316            "expected InsecureTransport, got: {err:?}"
3317        );
3318    }
3319
3320    #[test]
3321    fn bearer_over_http_loopback_is_allowed() {
3322        let result = HttpEnvironmentStore::new(
3323            Url::parse("http://127.0.0.1:8080").unwrap(),
3324            AuthMethod::Bearer("token".to_string()),
3325        );
3326        assert!(result.is_ok());
3327    }
3328
3329    #[test]
3330    fn bearer_over_https_is_allowed() {
3331        let result = HttpEnvironmentStore::new(
3332            Url::parse("https://example.com").unwrap(),
3333            AuthMethod::Bearer("token".to_string()),
3334        );
3335        assert!(result.is_ok());
3336    }
3337
3338    #[test]
3339    fn no_auth_over_http_is_allowed() {
3340        let result = HttpEnvironmentStore::new(
3341            Url::parse("http://192.0.2.1:8080").unwrap(),
3342            AuthMethod::None,
3343        );
3344        assert!(result.is_ok());
3345    }
3346
3347    // -----------------------------------------------------------------------
3348    // Object-safety compile guard (same as mutations.rs)
3349    // -----------------------------------------------------------------------
3350
3351    #[allow(dead_code)]
3352    fn _http_store_is_trait_object(store: &HttpEnvironmentStore) {
3353        let _dyn: &dyn EnvironmentMutations = store;
3354    }
3355}