1use 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#[derive(Debug, Clone)]
150pub enum AuthMethod {
151 None,
153 Bearer(String),
155 }
157
158const 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
179fn encode_segment(s: &str) -> String {
183 utf8_percent_encode(s, PATH_SEGMENT_ENCODE_SET).to_string()
184}
185
186#[derive(Debug, thiserror::Error)]
192pub enum ConstructionError {
193 #[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#[derive(Debug, Clone)]
209pub struct HttpEnvironmentStore {
210 client: Client,
211 base_url: Url,
212 auth_header_value: Option<String>,
215 cached_etag: Arc<Mutex<HashMap<String, StateEtag>>>,
226}
227
228fn 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
239fn 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
252fn 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 pub fn new(base_url: Url, auth: AuthMethod) -> Result<Self, ConstructionError> {
279 Self::with_client(Client::new(), base_url, auth)
280 }
281
282 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 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 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 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 fn env_path(&self, env_id: &EnvId, suffix: &str) -> String {
353 format!("environments/{}{suffix}", encode_segment(env_id.as_str()))
354 }
355
356 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 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 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 Some(etag) => {
452 self.remember_etag(expected_env, Some(etag));
453 envelope.validated(expected_env, idempotency_key)
454 }
455 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 self.remember_etag(expected_env, None);
477 Err(err)
478 }
479 }
480 }
481
482 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 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 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 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 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 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 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 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 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 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 fn load_env(&self, env_id: &EnvId) -> Result<Environment, StoreError> {
693 EnvironmentMutations::load_environment(self, env_id)
694 }
695
696 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
715fn 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 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
777fn 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#[derive(Debug, Deserialize)]
812struct MutationEnvelope<T> {
813 result: T,
814 #[serde(default)]
817 etag: Option<StateEtag>,
818 #[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 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
848fn mint_idempotency_key() -> String {
854 crate::cli::mint_idempotency_key().as_str().to_string()
855}
856
857fn 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 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 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 match status.as_u16() {
887 404 => StoreError::NotFound(EnvId::try_from("unknown").unwrap_or_else(|_| {
888 unreachable!("EnvId::try_from(\"unknown\") must succeed")
890 })),
891 409 => StoreError::Conflict(body_text),
892 400 | 422 => StoreError::InvalidArgument(body_text),
893 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
905const GATEWAY_DENIAL_POLICY: &str = "remote";
909
910fn 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 RemoteStoreError::AlreadyExists { detail } => StoreError::Conflict(detail),
933 RemoteStoreError::Conflict { detail } => StoreError::Conflict(detail),
934 RemoteStoreError::DependentNotFound { detail } => StoreError::DependentNotFound(detail),
935 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
961impl 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 #[derive(serde::Deserialize)]
1052 struct GetEnvResponse {
1053 environment: Environment,
1054 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
1892 fn success_with_wrong_audit_idempotency_key_is_rejected() {
1893 assert_audit_violation(
1894 |env| {
1895 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 #[test]
1918 fn success_2xx_with_garbage_body_maps_to_committed_after_save() {
1919 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 #[test]
1933 fn error_500_with_unauthorized_body_is_contract_violation() {
1934 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 #[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 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 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 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 #[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 #[test]
3193 fn mutation_envelope_with_extra_metadata_is_accepted() {
3194 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 #[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 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 #[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 #[allow(dead_code)]
3352 fn _http_store_is_trait_object(store: &HttpEnvironmentStore) {
3353 let _dyn: &dyn EnvironmentMutations = store;
3354 }
3355}