use greentic_deploy_spec::{
AuditDecision, AuditEvent, AuditResult, BackupManifest, BindingGenerationOutcome,
BundleDeployment, BundleId, CapabilitySlot, DeploymentId, EnvId, EnvPackBinding, Environment,
EnvironmentHostConfig, ExtensionBinding, ExtensionBindingPayload, ExtensionKeyedPayload,
IdempotencyKey, IdempotencyOutcome, MessagingBundleLinkPayload, MessagingEndpoint,
MessagingEndpointId, PackBindingPayload, RemoteStoreError, RestoreOutcome, RestoreRequest,
Revision, RevisionId, RollbackTrafficSplitPayload, RotateWebhookSecretPayload, StateEtag,
};
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use url::Url;
use super::mutations::{
AddBundlePayload, AddMessagingEndpointPayload, AddTrustedKeyPayload, ApplyTrafficSplitOutcome,
EnvironmentMutations, ExtensionKey, MigrateMergePayload, RemoveBundleOutcome,
RevisionTransitionOutcome, RollbackTrafficSplitOutcome, SetMessagingWelcomeFlowPayload,
SetTrafficSplitPayload, StageRevisionPayload, TrustRootAddOutcome, TrustRootRemoveOutcome,
TrustRootSeed, UpdateBundlePayload, UpdateEnvironmentPayload, WarmRevisionPayload,
};
use super::store::StoreError;
#[derive(Debug, Clone)]
pub enum AuthMethod {
None,
Bearer(String),
}
const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'`')
.add(b'{')
.add(b'}')
.add(b'/')
.add(b'%')
.add(b'.');
fn encode_segment(s: &str) -> String {
utf8_percent_encode(s, PATH_SEGMENT_ENCODE_SET).to_string()
}
#[derive(Debug, thiserror::Error)]
pub enum ConstructionError {
#[error(
"bearer auth over http:// is only allowed to loopback hosts; \
got `{0}` — use https:// or AuthMethod::None"
)]
InsecureTransport(String),
}
#[derive(Debug, Clone)]
pub struct HttpEnvironmentStore {
client: Client,
base_url: Url,
auth_header_value: Option<String>,
}
fn normalize_base_url(mut base_url: Url) -> Url {
if !base_url.path().ends_with('/') {
let normalized = format!("{}/", base_url.path());
base_url.set_path(&normalized);
}
base_url
}
fn is_loopback(url: &Url) -> bool {
match url.host_str() {
Some("localhost") => true,
Some(host) => host
.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false),
None => false,
}
}
fn validate_transport(url: &Url, auth: &AuthMethod) -> Result<(), ConstructionError> {
if let AuthMethod::Bearer(_) = auth
&& url.scheme() == "http"
{
if is_loopback(url) {
tracing::warn!(
url = %url,
"bearer auth over http:// to loopback — acceptable for dev, \
not for production"
);
} else {
return Err(ConstructionError::InsecureTransport(
url.host_str().unwrap_or("<none>").to_string(),
));
}
}
Ok(())
}
impl HttpEnvironmentStore {
pub fn new(base_url: Url, auth: AuthMethod) -> Result<Self, ConstructionError> {
Self::with_client(Client::new(), base_url, auth)
}
pub fn with_client(
client: Client,
base_url: Url,
auth: AuthMethod,
) -> Result<Self, ConstructionError> {
validate_transport(&base_url, &auth)?;
let auth_header_value = match auth {
AuthMethod::Bearer(token) => Some(format!("Bearer {token}")),
AuthMethod::None => None,
};
Ok(Self {
client,
base_url: normalize_base_url(base_url),
auth_header_value,
})
}
fn url(&self, path: &str) -> Result<Url, StoreError> {
self.base_url
.join(path)
.map_err(|e| StoreError::Conflict(format!("transport: invalid URL path `{path}`: {e}")))
}
fn env_path(&self, env_id: &EnvId, suffix: &str) -> String {
format!("environments/{}{suffix}", encode_segment(env_id.as_str()))
}
fn send<P: Serialize, R: serde::de::DeserializeOwned>(
&self,
method: reqwest::Method,
path: &str,
idempotency_key: Option<&str>,
body: Option<&P>,
) -> Result<R, StoreError> {
let url = self.url(path)?;
let mut builder = self
.client
.request(method, url)
.header("Content-Type", "application/json")
.header("Accept", "application/json");
if let Some(value) = &self.auth_header_value {
builder = builder.header("Authorization", value);
}
if let Some(key) = idempotency_key {
builder = builder.header("Idempotency-Key", key);
}
if let Some(payload) = body {
builder = builder.json(payload);
}
let response = builder
.send()
.map_err(|e| StoreError::Conflict(format!("transport: {e}")))?;
let status = response.status();
if status.is_success() {
if status == reqwest::StatusCode::NO_CONTENT {
return serde_json::from_str("null").map_err(|e| {
committed_after_save(
format!("transport: cannot deserialize 204 body: {e}"),
idempotency_key,
)
});
}
response.json::<R>().map_err(|e| {
committed_after_save(
format!("transport: invalid response body: {e}"),
idempotency_key,
)
})
} else {
Err(map_error_response(status, response))
}
}
fn send_mutation<P: Serialize, R: serde::de::DeserializeOwned>(
&self,
expected_env: &EnvId,
method: reqwest::Method,
path: &str,
idempotency_key: Option<&str>,
body: Option<&P>,
) -> Result<R, StoreError> {
let envelope: MutationEnvelope<R> = self.send(method, path, idempotency_key, body)?;
envelope.validated(expected_env, idempotency_key)
}
fn send_mutation_no_body<R: serde::de::DeserializeOwned>(
&self,
expected_env: &EnvId,
method: reqwest::Method,
path: &str,
idempotency_key: Option<&str>,
) -> Result<R, StoreError> {
self.send_mutation::<(), R>(expected_env, method, path, idempotency_key, None)
}
pub fn create_backup(
&self,
env_id: &EnvId,
idempotency_key: &IdempotencyKey,
) -> Result<BackupManifest, StoreError> {
self.send_mutation_no_body(
env_id,
reqwest::Method::POST,
&self.env_path(env_id, "/backups"),
Some(idempotency_key.as_str()),
)
}
pub fn list_backups(&self, env_id: &EnvId) -> Result<Vec<BackupManifest>, StoreError> {
#[derive(Deserialize)]
struct BackupsResponse {
backups: Vec<BackupManifest>,
}
let response: BackupsResponse = self.send::<(), _>(
reqwest::Method::GET,
&self.env_path(env_id, "/backups"),
None,
None,
)?;
Ok(response.backups)
}
pub fn delete_backup(
&self,
env_id: &EnvId,
backup_id: &str,
idempotency_key: &IdempotencyKey,
) -> Result<(), StoreError> {
let path = format!(
"{}/{}",
self.env_path(env_id, "/backups"),
encode_segment(backup_id)
);
let _ack: serde_json::Value = self.send_mutation_no_body(
env_id,
reqwest::Method::DELETE,
&path,
Some(idempotency_key.as_str()),
)?;
Ok(())
}
pub fn restore(
&self,
env_id: &EnvId,
request: &RestoreRequest,
idempotency_key: &IdempotencyKey,
) -> Result<RestoreOutcome, StoreError> {
self.send_mutation(
env_id,
reqwest::Method::POST,
&self.env_path(env_id, "/restore"),
Some(idempotency_key.as_str()),
Some(request),
)
}
}
fn validate_success_audit(
audit: Option<&AuditEvent>,
expected_env: &EnvId,
sent_idempotency_key: Option<&str>,
) -> Result<(), StoreError> {
let Some(audit) = audit else {
return Err(StoreError::Conflict(
"A8 contract violation: success response is missing the audit record (§4)".to_string(),
));
};
if let AuditDecision::Deny { policy, reason } = &audit.authorization {
return Err(StoreError::Conflict(format!(
"A8 contract violation: success response carries a deny audit decision \
(policy `{policy}`: {reason}); a denial must be a 403"
)));
}
match &audit.result {
AuditResult::Ok => {}
other => {
return Err(StoreError::Conflict(format!(
"A8 contract violation: success response carries a non-ok audit result \
({other:?})"
)));
}
}
if audit.env_id != expected_env.as_str() {
return Err(StoreError::Conflict(format!(
"A8 contract violation: audit record names env `{}` but the request targeted \
env `{expected_env}`",
audit.env_id
)));
}
if let Some(sent) = sent_idempotency_key {
let audit_key = audit.idempotency_key.as_deref();
if audit_key != Some(sent) {
return Err(StoreError::Conflict(format!(
"A8 contract violation: audit record idempotency key `{}` does not match \
the request's key `{sent}` — the record does not belong to this mutation",
audit_key.unwrap_or("<missing>")
)));
}
}
Ok(())
}
fn committed_after_save(message: String, idempotency_key: Option<&str>) -> StoreError {
let replay = match idempotency_key {
Some(key) => format!(" — replay with Idempotency-Key `{key}` instead of re-applying"),
None => {
" — re-running with the SAME Idempotency-Key replays instead of re-applying".to_string()
}
};
StoreError::CommittedAfterSave(Box::new(StoreError::Conflict(format!(
"{message} — the server reported success (2xx), so the mutation may already be \
committed{replay}"
))))
}
#[derive(Debug, Deserialize)]
struct MutationEnvelope<T> {
result: T,
#[serde(default)]
#[allow(dead_code)]
etag: Option<StateEtag>,
#[serde(default)]
#[allow(dead_code)]
generation: Option<u64>,
#[serde(default)]
#[allow(dead_code)]
idempotency: Option<IdempotencyOutcome>,
#[serde(default)]
audit: Option<AuditEvent>,
}
impl<T> MutationEnvelope<T> {
fn validated(
self,
expected_env: &EnvId,
sent_idempotency_key: Option<&str>,
) -> Result<T, StoreError> {
validate_success_audit(self.audit.as_ref(), expected_env, sent_idempotency_key)
.map_err(|inner| committed_after_save(inner.to_string(), sent_idempotency_key))?;
Ok(self.result)
}
}
fn mint_idempotency_key() -> String {
crate::cli::mint_idempotency_key().as_str().to_string()
}
fn map_error_response(
status: reqwest::StatusCode,
response: reqwest::blocking::Response,
) -> StoreError {
let body_text = response.text().unwrap_or_default();
if let Ok(remote_err) = serde_json::from_str::<RemoteStoreError>(&body_text) {
let actual = status.as_u16();
let expected = remote_err.http_status();
let consistent = actual == expected
|| (actual == 401 && matches!(remote_err, RemoteStoreError::Unauthorized { .. }));
if consistent {
return map_remote_error(remote_err);
}
return StoreError::Conflict(format!(
"A8 contract violation: HTTP status {actual} contradicts the A8 error body \
(kind expects {expected}): {remote_err}"
));
}
match status.as_u16() {
404 => StoreError::NotFound(EnvId::try_from("unknown").unwrap_or_else(|_| {
unreachable!("EnvId::try_from(\"unknown\") must succeed")
})),
409 => StoreError::Conflict(body_text),
400 | 422 => StoreError::InvalidArgument(body_text),
401 | 403 => StoreError::Unauthorized {
policy: GATEWAY_DENIAL_POLICY.to_string(),
reason: body_text,
},
501 => StoreError::NotYetImplemented(body_text),
_ => StoreError::Conflict(format!("server ({status}): {body_text}")),
}
}
const GATEWAY_DENIAL_POLICY: &str = "remote";
fn map_remote_error(err: RemoteStoreError) -> StoreError {
match err {
RemoteStoreError::NotFound => StoreError::NotFound(
EnvId::try_from("unknown")
.unwrap_or_else(|_| unreachable!("EnvId::try_from(\"unknown\") must succeed")),
),
RemoteStoreError::PreconditionFailed(conflict) => {
StoreError::Conflict(format!("precondition failed: {conflict:?}"))
}
RemoteStoreError::PreconditionRequired { detail } => {
StoreError::Conflict(format!("precondition required: {detail}"))
}
RemoteStoreError::IdempotencyConflict { reason } => {
StoreError::Conflict(format!("idempotency conflict: {reason}"))
}
RemoteStoreError::Unauthorized { policy, reason } => {
StoreError::Unauthorized { policy, reason }
}
RemoteStoreError::AlreadyExists { detail } => StoreError::Conflict(detail),
RemoteStoreError::Conflict { detail } => StoreError::Conflict(detail),
RemoteStoreError::DependentNotFound { detail } => StoreError::DependentNotFound(detail),
RemoteStoreError::HealthGateFailed {
revision_id,
failed_checks,
message,
} => StoreError::Lifecycle(Box::new(
crate::environment::LifecycleError::HealthGateFailed {
revision_id,
failed_checks,
message,
},
)),
RemoteStoreError::InvalidRequest { detail } => StoreError::InvalidArgument(detail),
RemoteStoreError::IntegrityMismatch { expected, actual } => StoreError::InvalidArgument(
format!("integrity mismatch: expected {expected}, computed {actual}"),
),
RemoteStoreError::NotYetImplemented { detail } => StoreError::NotYetImplemented(detail),
RemoteStoreError::Internal { message } => {
StoreError::Conflict(format!("server: {message}"))
}
}
}
impl EnvironmentMutations for HttpEnvironmentStore {
fn create_environment(
&self,
env_id: &EnvId,
name: String,
host_config: EnvironmentHostConfig,
) -> Result<Environment, StoreError> {
let idem_key = mint_idempotency_key();
let req = greentic_deploy_spec::CreateEnvironmentPayload {
env_id: env_id.clone(),
name,
host_config,
};
self.send_mutation(
env_id,
reqwest::Method::POST,
"environments",
Some(&idem_key),
Some(&req),
)
}
fn update_environment(
&self,
env_id: &EnvId,
patch: UpdateEnvironmentPayload,
) -> Result<Environment, StoreError> {
let idem_key = mint_idempotency_key();
self.send_mutation(
env_id,
reqwest::Method::PATCH,
&self.env_path(env_id, ""),
Some(&idem_key),
Some(&patch),
)
}
fn migrate_merge_bindings(
&self,
target_env_id: &EnvId,
payload: MigrateMergePayload,
) -> Result<(Vec<String>, Vec<String>), StoreError> {
let idem_key = mint_idempotency_key();
let resp: greentic_deploy_spec::MergeReport = self.send_mutation(
target_env_id,
reqwest::Method::POST,
&format!(
"environments/{}/migrate-bindings",
encode_segment(target_env_id.as_str())
),
Some(&idem_key),
Some(&payload),
)?;
Ok((resp.merged_slots, resp.merged_extensions))
}
fn stage_revision(
&self,
env_id: &EnvId,
payload: StageRevisionPayload,
idempotency_key: IdempotencyKey,
) -> Result<Revision, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
self.send_mutation(
env_id,
reqwest::Method::POST,
&self.env_path(env_id, "/revisions"),
Some(&idem_key),
Some(&payload),
)
}
fn warm_revision(
&self,
env_id: &EnvId,
payload: WarmRevisionPayload,
idempotency_key: IdempotencyKey,
) -> Result<RevisionTransitionOutcome, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let rid = payload.revision_id;
self.send_mutation(
env_id,
reqwest::Method::POST,
&format!(
"environments/{}/revisions/{}/warm",
encode_segment(env_id.as_str()),
encode_segment(&rid.to_string()),
),
Some(&idem_key),
Some(&payload),
)
}
fn drain_revision(
&self,
env_id: &EnvId,
revision_id: RevisionId,
idempotency_key: IdempotencyKey,
) -> Result<RevisionTransitionOutcome, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
self.send_mutation_no_body(
env_id,
reqwest::Method::POST,
&format!(
"environments/{}/revisions/{}/drain",
encode_segment(env_id.as_str()),
encode_segment(&revision_id.to_string()),
),
Some(&idem_key),
)
}
fn archive_revision(
&self,
env_id: &EnvId,
revision_id: RevisionId,
idempotency_key: IdempotencyKey,
) -> Result<RevisionTransitionOutcome, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
self.send_mutation_no_body(
env_id,
reqwest::Method::POST,
&format!(
"environments/{}/revisions/{}/archive",
encode_segment(env_id.as_str()),
encode_segment(&revision_id.to_string()),
),
Some(&idem_key),
)
}
fn add_bundle(
&self,
env_id: &EnvId,
payload: AddBundlePayload,
idempotency_key: IdempotencyKey,
) -> Result<BundleDeployment, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
self.send_mutation(
env_id,
reqwest::Method::POST,
&self.env_path(env_id, "/bundles"),
Some(&idem_key),
Some(&payload),
)
}
fn update_bundle(
&self,
env_id: &EnvId,
payload: UpdateBundlePayload,
idempotency_key: IdempotencyKey,
) -> Result<BundleDeployment, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let did = payload.deployment_id.to_string();
self.send_mutation(
env_id,
reqwest::Method::PATCH,
&format!(
"environments/{}/bundles/{}",
encode_segment(env_id.as_str()),
encode_segment(&did),
),
Some(&idem_key),
Some(&payload),
)
}
fn remove_bundle(
&self,
env_id: &EnvId,
deployment_id: DeploymentId,
idempotency_key: IdempotencyKey,
) -> Result<RemoveBundleOutcome, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
self.send_mutation_no_body(
env_id,
reqwest::Method::DELETE,
&format!(
"environments/{}/bundles/{}",
encode_segment(env_id.as_str()),
encode_segment(&deployment_id.to_string()),
),
Some(&idem_key),
)
}
fn add_pack_binding(
&self,
env_id: &EnvId,
binding: EnvPackBinding,
idempotency_key: IdempotencyKey,
) -> Result<EnvPackBinding, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let req = PackBindingPayload { binding };
self.send_mutation(
env_id,
reqwest::Method::POST,
&self.env_path(env_id, "/packs"),
Some(&idem_key),
Some(&req),
)
}
fn update_pack_binding(
&self,
env_id: &EnvId,
slot: CapabilitySlot,
binding: EnvPackBinding,
idempotency_key: IdempotencyKey,
) -> Result<(EnvPackBinding, u64), StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let req = PackBindingPayload { binding };
let resp: BindingGenerationOutcome<EnvPackBinding> = self.send_mutation(
env_id,
reqwest::Method::PATCH,
&format!(
"environments/{}/packs/{}",
encode_segment(env_id.as_str()),
encode_segment(&slot.to_string()),
),
Some(&idem_key),
Some(&req),
)?;
Ok((resp.binding, resp.generation))
}
fn remove_pack_binding(
&self,
env_id: &EnvId,
slot: CapabilitySlot,
idempotency_key: IdempotencyKey,
) -> Result<(EnvPackBinding, u64), StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let resp: BindingGenerationOutcome<EnvPackBinding> = self.send_mutation_no_body(
env_id,
reqwest::Method::DELETE,
&format!(
"environments/{}/packs/{}",
encode_segment(env_id.as_str()),
encode_segment(&slot.to_string()),
),
Some(&idem_key),
)?;
Ok((resp.binding, resp.generation))
}
fn rollback_pack_binding(
&self,
env_id: &EnvId,
slot: CapabilitySlot,
idempotency_key: IdempotencyKey,
) -> Result<(EnvPackBinding, u64), StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let resp: BindingGenerationOutcome<EnvPackBinding> = self.send_mutation_no_body(
env_id,
reqwest::Method::POST,
&format!(
"environments/{}/packs/{}/rollback",
encode_segment(env_id.as_str()),
encode_segment(&slot.to_string()),
),
Some(&idem_key),
)?;
Ok((resp.binding, resp.generation))
}
fn add_extension_binding(
&self,
env_id: &EnvId,
binding: ExtensionBinding,
idempotency_key: IdempotencyKey,
) -> Result<ExtensionBinding, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let req = ExtensionBindingPayload { binding };
self.send_mutation(
env_id,
reqwest::Method::POST,
&format!(
"environments/{}/extensions",
encode_segment(env_id.as_str())
),
Some(&idem_key),
Some(&req),
)
}
fn update_extension_binding(
&self,
env_id: &EnvId,
key: ExtensionKey,
binding: ExtensionBinding,
idempotency_key: IdempotencyKey,
) -> Result<(ExtensionBinding, u64), StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let req = ExtensionKeyedPayload {
key,
binding: Some(binding),
};
let resp: BindingGenerationOutcome<ExtensionBinding> = self.send_mutation(
env_id,
reqwest::Method::PATCH,
&format!(
"environments/{}/extensions",
encode_segment(env_id.as_str())
),
Some(&idem_key),
Some(&req),
)?;
Ok((resp.binding, resp.generation))
}
fn remove_extension_binding(
&self,
env_id: &EnvId,
key: ExtensionKey,
idempotency_key: IdempotencyKey,
) -> Result<(ExtensionBinding, u64), StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let req = ExtensionKeyedPayload { key, binding: None };
let resp: BindingGenerationOutcome<ExtensionBinding> = self.send_mutation(
env_id,
reqwest::Method::DELETE,
&format!(
"environments/{}/extensions",
encode_segment(env_id.as_str())
),
Some(&idem_key),
Some(&req),
)?;
Ok((resp.binding, resp.generation))
}
fn rollback_extension_binding(
&self,
env_id: &EnvId,
key: ExtensionKey,
idempotency_key: IdempotencyKey,
) -> Result<(ExtensionBinding, u64), StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let req = ExtensionKeyedPayload { key, binding: None };
let resp: BindingGenerationOutcome<ExtensionBinding> = self.send_mutation(
env_id,
reqwest::Method::POST,
&format!(
"environments/{}/extensions/rollback",
encode_segment(env_id.as_str())
),
Some(&idem_key),
Some(&req),
)?;
Ok((resp.binding, resp.generation))
}
fn set_traffic_split(
&self,
env_id: &EnvId,
payload: SetTrafficSplitPayload,
idempotency_key: IdempotencyKey,
) -> Result<ApplyTrafficSplitOutcome, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
self.send_mutation(
env_id,
reqwest::Method::POST,
&self.env_path(env_id, "/traffic"),
Some(&idem_key),
Some(&payload),
)
}
fn rollback_traffic_split(
&self,
env_id: &EnvId,
deployment_id: DeploymentId,
idempotency_key: IdempotencyKey,
) -> Result<RollbackTrafficSplitOutcome, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let req = RollbackTrafficSplitPayload { deployment_id };
self.send_mutation(
env_id,
reqwest::Method::POST,
&format!(
"environments/{}/traffic/rollback",
encode_segment(env_id.as_str())
),
Some(&idem_key),
Some(&req),
)
}
fn add_messaging_endpoint(
&self,
env_id: &EnvId,
payload: AddMessagingEndpointPayload,
idempotency_key: IdempotencyKey,
) -> Result<MessagingEndpoint, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
self.send_mutation(
env_id,
reqwest::Method::POST,
&self.env_path(env_id, "/messaging"),
Some(&idem_key),
Some(&payload),
)
}
fn link_messaging_bundle(
&self,
env_id: &EnvId,
endpoint_id: MessagingEndpointId,
bundle_id: BundleId,
updated_by: String,
idempotency_key: IdempotencyKey,
) -> Result<MessagingEndpoint, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let req = MessagingBundleLinkPayload {
bundle_id,
updated_by,
};
self.send_mutation(
env_id,
reqwest::Method::POST,
&format!(
"environments/{}/messaging/{}/link",
encode_segment(env_id.as_str()),
encode_segment(&endpoint_id.to_string()),
),
Some(&idem_key),
Some(&req),
)
}
fn unlink_messaging_bundle(
&self,
env_id: &EnvId,
endpoint_id: MessagingEndpointId,
bundle_id: BundleId,
updated_by: String,
idempotency_key: IdempotencyKey,
) -> Result<MessagingEndpoint, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let req = MessagingBundleLinkPayload {
bundle_id,
updated_by,
};
self.send_mutation(
env_id,
reqwest::Method::POST,
&format!(
"environments/{}/messaging/{}/unlink",
encode_segment(env_id.as_str()),
encode_segment(&endpoint_id.to_string()),
),
Some(&idem_key),
Some(&req),
)
}
fn set_messaging_welcome_flow(
&self,
env_id: &EnvId,
payload: SetMessagingWelcomeFlowPayload,
idempotency_key: IdempotencyKey,
) -> Result<MessagingEndpoint, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let eid = payload.endpoint_id.to_string();
self.send_mutation(
env_id,
reqwest::Method::POST,
&format!(
"environments/{}/messaging/{}/welcome-flow",
encode_segment(env_id.as_str()),
encode_segment(&eid),
),
Some(&idem_key),
Some(&payload),
)
}
fn remove_messaging_endpoint(
&self,
env_id: &EnvId,
endpoint_id: MessagingEndpointId,
) -> Result<MessagingEndpointId, StoreError> {
let idem_key = mint_idempotency_key();
self.send_mutation_no_body(
env_id,
reqwest::Method::DELETE,
&format!(
"environments/{}/messaging/{}",
encode_segment(env_id.as_str()),
encode_segment(&endpoint_id.to_string()),
),
Some(&idem_key),
)
}
fn rotate_messaging_webhook_secret(
&self,
env_id: &EnvId,
endpoint_id: MessagingEndpointId,
updated_by: String,
idempotency_key: IdempotencyKey,
) -> Result<MessagingEndpoint, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let req = RotateWebhookSecretPayload { updated_by };
self.send_mutation(
env_id,
reqwest::Method::POST,
&format!(
"environments/{}/messaging/{}/rotate-secret",
encode_segment(env_id.as_str()),
encode_segment(&endpoint_id.to_string()),
),
Some(&idem_key),
Some(&req),
)
}
fn bootstrap_trust_root(&self, env_id: &EnvId) -> Result<TrustRootSeed, StoreError> {
let idem_key = mint_idempotency_key();
self.send_mutation_no_body(
env_id,
reqwest::Method::POST,
&self.env_path(env_id, "/trust-root/bootstrap"),
Some(&idem_key),
)
}
fn seed_trust_root_if_absent(
&self,
env_id: &EnvId,
) -> Result<Option<TrustRootSeed>, StoreError> {
let idem_key = mint_idempotency_key();
self.send_mutation_no_body(
env_id,
reqwest::Method::POST,
&self.env_path(env_id, "/trust-root/seed"),
Some(&idem_key),
)
}
fn add_trusted_key(
&self,
env_id: &EnvId,
key_id: String,
public_key_pem: String,
idempotency_key: IdempotencyKey,
) -> Result<TrustRootAddOutcome, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
let req = AddTrustedKeyPayload {
key_id,
public_key_pem,
};
self.send_mutation(
env_id,
reqwest::Method::POST,
&self.env_path(env_id, "/trust-root/keys"),
Some(&idem_key),
Some(&req),
)
}
fn remove_trusted_key(
&self,
env_id: &EnvId,
key_id: String,
idempotency_key: IdempotencyKey,
) -> Result<TrustRootRemoveOutcome, StoreError> {
let idem_key = idempotency_key.as_str().to_string();
self.send_mutation_no_body(
env_id,
reqwest::Method::DELETE,
&format!(
"environments/{}/trust-root/keys/{}",
encode_segment(env_id.as_str()),
encode_segment(&key_id),
),
Some(&idem_key),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use greentic_deploy_spec::{PackId, RevisionLifecycle};
use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpListener};
use std::path::PathBuf;
use std::sync::Arc;
struct MockServer {
addr: SocketAddr,
_handle: std::thread::JoinHandle<()>,
}
type CheckFn = Arc<dyn Fn(&str, &str, &[u8]) + Send + Sync>;
fn start_mock(responses: Vec<(u16, &str)>, check: Option<CheckFn>) -> MockServer {
let responses: Vec<(u16, String)> = responses
.into_iter()
.map(|(s, b)| (s, b.to_string()))
.collect();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
for (status, body) in responses {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream);
let mut lines: Vec<String> = Vec::new();
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
if trimmed.is_empty() {
break;
}
lines.push(trimmed.to_string());
}
let content_length: usize = lines
.iter()
.find(|l| l.to_lowercase().starts_with("content-length:"))
.and_then(|l| l.split(':').nth(1))
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0);
let mut req_body = vec![0u8; content_length];
if content_length > 0 {
std::io::Read::read_exact(&mut reader, &mut req_body).unwrap();
}
if let Some(ref check_fn) = check {
let request_line = lines.first().map(|s| s.as_str()).unwrap_or("");
let headers = lines[1..].join("\r\n");
check_fn(request_line, &headers, &req_body);
}
let body = if body.contains("{{IDEMPOTENCY_KEY}}") {
let idem_val = lines
.iter()
.find(|l| l.to_lowercase().starts_with("idempotency-key:"))
.and_then(|l| l.split_once(':').map(|(_, v)| v.trim().to_string()))
.unwrap_or_default();
body.replace("{{IDEMPOTENCY_KEY}}", &idem_val)
} else {
body
};
let status_text = match status {
200 => "OK",
201 => "Created",
204 => "No Content",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
409 => "Conflict",
422 => "Unprocessable Entity",
500 => "Internal Server Error",
501 => "Not Implemented",
_ => "Unknown",
};
let response = format!(
"HTTP/1.1 {status} {status_text}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let stream_ref = reader.get_mut();
stream_ref.write_all(response.as_bytes()).unwrap();
stream_ref.flush().unwrap();
}
});
MockServer {
addr,
_handle: handle,
}
}
fn mock_store(addr: SocketAddr, auth: AuthMethod) -> HttpEnvironmentStore {
HttpEnvironmentStore::new(Url::parse(&format!("http://{addr}")).unwrap(), auth).unwrap()
}
fn happy_store(status: u16, body: &str) -> (MockServer, HttpEnvironmentStore) {
let mock = start_mock(vec![(status, body)], None);
let store = mock_store(mock.addr, AuthMethod::None);
(mock, store)
}
fn wrap_mutation(domain: serde_json::Value) -> String {
serde_json::json!({
"result": domain,
"etag": "sha256:test",
"generation": 1,
"idempotency": {"idempotency": "applied"},
"audit": {
"schema": "greentic.audit-event.v1",
"event_id": "01TEST000000000000000000AA",
"ts": "2026-06-09T12:00:00Z",
"actor": {"kind": "operator"},
"env_id": "local",
"noun": "test",
"verb": "test",
"target": null,
"authorization": {"decision": "allow", "policy": "local-only", "reason": "test"},
"result": {"outcome": "ok"},
"idempotency_key": "{{IDEMPOTENCY_KEY}}"
}
})
.to_string()
}
fn env_id() -> EnvId {
EnvId::try_from("local").unwrap()
}
fn idem() -> IdempotencyKey {
IdempotencyKey::new("01JABC000000000000000000ZZ").unwrap()
}
fn sample_env_domain() -> serde_json::Value {
serde_json::json!({
"schema": "greentic.environment.v1",
"environment_id": "local",
"name": "test",
"host_config": {"env_id": "local"},
"packs": [],
"bundles": [],
"revisions": [],
"traffic_splits": [],
"messaging_endpoints": [],
"extensions": [],
"revocation": {},
"retention": {},
"health": {}
})
}
fn wrap_mutation_tweaked(
domain: serde_json::Value,
tweak: impl FnOnce(&mut serde_json::Value),
) -> String {
let mut envelope: serde_json::Value = serde_json::from_str(&wrap_mutation(domain)).unwrap();
tweak(&mut envelope);
envelope.to_string()
}
fn unwrap_committed_conflict(result: Result<Environment, StoreError>) -> String {
match result {
Err(StoreError::CommittedAfterSave(inner)) => match *inner {
StoreError::Conflict(msg) => msg,
other => panic!("expected inner Conflict, got {other:?}"),
},
other => panic!("expected CommittedAfterSave, got {other:?}"),
}
}
fn assert_audit_violation(tweak: impl FnOnce(&mut serde_json::Value), expected_substr: &str) {
let body = wrap_mutation_tweaked(sample_env_domain(), tweak);
let (_mock, store) = happy_store(200, &body);
let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
let msg = unwrap_committed_conflict(result);
assert!(
msg.contains(expected_substr),
"expected `{expected_substr}` violation, got: {msg}"
);
}
#[test]
fn success_without_audit_is_rejected() {
assert_audit_violation(
|env| {
env.as_object_mut().unwrap().remove("audit");
},
"missing the audit record",
);
}
#[test]
fn success_with_deny_audit_is_rejected() {
assert_audit_violation(
|env| {
env["audit"]["authorization"] = serde_json::json!({
"decision": "deny", "policy": "rbac-v1", "reason": "nope"
});
},
"deny audit decision",
);
}
#[test]
fn success_with_non_ok_audit_result_is_rejected() {
assert_audit_violation(
|env| {
env["audit"]["result"] = serde_json::json!({
"outcome": "error", "kind": "store", "message": "boom"
});
},
"non-ok audit result",
);
}
#[test]
fn success_with_mismatched_audit_env_is_rejected() {
assert_audit_violation(
|env| {
env["audit"]["env_id"] = serde_json::json!("other-env");
},
"names env `other-env`",
);
}
#[test]
fn success_with_wrong_audit_idempotency_key_is_rejected() {
assert_audit_violation(
|env| {
env["audit"]["idempotency_key"] = serde_json::json!("01WRONG00000000000000000XX");
},
"does not belong",
);
}
#[test]
fn success_with_missing_audit_idempotency_key_is_rejected() {
assert_audit_violation(
|env| {
env["audit"]
.as_object_mut()
.unwrap()
.remove("idempotency_key");
},
"does not belong",
);
}
#[test]
fn success_2xx_with_garbage_body_maps_to_committed_after_save() {
let (_mock, store) = happy_store(200, "this is not json");
let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
let msg = unwrap_committed_conflict(result);
assert!(
msg.contains("already be committed"),
"expected committed guidance, got: {msg}"
);
}
#[test]
fn error_500_with_unauthorized_body_is_contract_violation() {
let err_body = serde_json::json!({
"kind": "unauthorized",
"policy": "rbac-v1",
"reason": "nope"
});
let (_mock, store) = happy_store(500, &err_body.to_string());
let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
match result {
Err(StoreError::Conflict(msg)) => {
assert!(
msg.contains("contradicts"),
"expected contract violation, got: {msg}"
);
}
other => panic!("expected Conflict(contradicts...), got {other:?}"),
}
}
#[test]
fn error_401_with_unauthorized_body_maps_to_unauthorized() {
let err_body = serde_json::json!({
"kind": "unauthorized",
"policy": "rbac-v1",
"reason": "expired token"
});
let (_mock, store) = happy_store(401, &err_body.to_string());
let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
match result {
Err(StoreError::Unauthorized { policy, reason }) => {
assert_eq!(policy, "rbac-v1");
assert_eq!(reason, "expired token");
}
other => panic!("expected Unauthorized, got {other:?}"),
}
}
#[test]
fn create_environment_happy_path() {
let domain = serde_json::json!({
"schema": "greentic.environment.v1",
"environment_id": "local",
"name": "test",
"host_config": {"env_id": "local"},
"packs": [],
"bundles": [],
"revisions": [],
"traffic_splits": [],
"messaging_endpoints": [],
"extensions": [],
"revocation": {},
"retention": {},
"health": {}
});
let body = wrap_mutation(domain);
let (_mock, store) = happy_store(201, &body);
let result = store.create_environment(
&env_id(),
"test".to_string(),
EnvironmentHostConfig {
env_id: env_id(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
},
);
assert!(result.is_ok());
assert_eq!(result.unwrap().name, "test");
}
#[test]
fn create_environment_conflict_returns_conflict() {
let err_body = serde_json::json!({
"kind": "idempotency-conflict",
"reason": "environment already exists"
});
let (_mock, store) = happy_store(409, &err_body.to_string());
let result = store.create_environment(
&env_id(),
"test".to_string(),
EnvironmentHostConfig {
env_id: env_id(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
},
);
assert!(matches!(result, Err(StoreError::Conflict(_))));
}
#[test]
fn update_environment_happy_path() {
let domain = serde_json::json!({
"schema": "greentic.environment.v1",
"environment_id": "local",
"name": "updated",
"host_config": {"env_id": "local"},
"packs": [],
"bundles": [],
"revisions": [],
"traffic_splits": [],
"messaging_endpoints": [],
"extensions": [],
"revocation": {},
"retention": {},
"health": {}
});
let body = wrap_mutation(domain);
let (_mock, store) = happy_store(200, &body);
let result = store.update_environment(
&env_id(),
UpdateEnvironmentPayload {
name: Some("updated".to_string()),
..Default::default()
},
);
assert!(result.is_ok());
assert_eq!(result.unwrap().name, "updated");
}
#[test]
fn update_environment_not_found() {
let err_body = serde_json::json!({"kind": "not-found"});
let (_mock, store) = happy_store(404, &err_body.to_string());
let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
assert!(matches!(result, Err(StoreError::NotFound(_))));
}
#[test]
fn migrate_merge_bindings_happy_path() {
let domain = serde_json::json!({
"merged_slots": ["messaging"],
"merged_extensions": ["capability/memory/long-term"]
});
let body = wrap_mutation(domain);
let (_mock, store) = happy_store(200, &body);
let result = store.migrate_merge_bindings(
&env_id(),
MigrateMergePayload {
packs: Vec::new(),
extensions: Vec::new(),
seed_if_missing: None,
},
);
assert!(result.is_ok());
let (slots, exts) = result.unwrap();
assert_eq!(slots, vec!["messaging"]);
assert_eq!(exts, vec!["capability/memory/long-term"]);
}
fn sample_revision_response() -> String {
wrap_mutation(serde_json::json!({
"schema": "greentic.revision.v1",
"revision_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
"env_id": "local",
"bundle_id": "fast2flow",
"deployment_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
"sequence": 1,
"created_at": "2026-06-09T12:00:00Z",
"bundle_digest": "sha256:00",
"pack_list": [],
"pack_list_lock_ref": "",
"pack_config_refs": [],
"config_digest": "sha256:00",
"signature_sidecar_ref": "rev.sig",
"lifecycle": "staged",
"staged_at": "2026-06-09T12:00:00Z",
"drain_seconds": 30,
"abort_metrics": []
}))
}
#[test]
fn stage_revision_happy_path() {
let body = sample_revision_response();
let (_mock, store) = happy_store(201, &body);
let result = store.stage_revision(
&env_id(),
StageRevisionPayload {
revision_id: RevisionId::new(),
deployment_id: DeploymentId::new(),
bundle_digest: "sha256:00".to_string(),
pack_list: Vec::new(),
pack_list_lock_ref: PathBuf::new(),
pack_config_refs: Vec::new(),
config_digest: "sha256:00".to_string(),
signature_sidecar_ref: PathBuf::from("rev.sig"),
drain_seconds: 30,
},
idem(),
);
assert!(result.is_ok());
}
#[test]
fn stage_revision_422_returns_invalid_argument() {
let err_body = serde_json::json!({
"kind": "integrity-mismatch",
"expected": "abc",
"actual": "def"
});
let (_mock, store) = happy_store(422, &err_body.to_string());
let result = store.stage_revision(
&env_id(),
StageRevisionPayload {
revision_id: RevisionId::new(),
deployment_id: DeploymentId::new(),
bundle_digest: "sha256:00".to_string(),
pack_list: Vec::new(),
pack_list_lock_ref: PathBuf::new(),
pack_config_refs: Vec::new(),
config_digest: "sha256:00".to_string(),
signature_sidecar_ref: PathBuf::from("rev.sig"),
drain_seconds: 30,
},
idem(),
);
assert!(matches!(result, Err(StoreError::InvalidArgument(_))));
}
fn sample_transition_response(lifecycle: &str) -> String {
wrap_mutation(serde_json::json!({
"revision": {
"schema": "greentic.revision.v1",
"revision_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
"env_id": "local",
"bundle_id": "fast2flow",
"deployment_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
"sequence": 1,
"created_at": "2026-06-09T12:00:00Z",
"bundle_digest": "sha256:00",
"pack_list": [],
"pack_list_lock_ref": "",
"pack_config_refs": [],
"config_digest": "sha256:00",
"signature_sidecar_ref": "rev.sig",
"lifecycle": lifecycle,
"staged_at": "2026-06-09T12:00:00Z",
"drain_seconds": 30,
"abort_metrics": []
},
"environment": {
"schema": "greentic.environment.v1",
"environment_id": "local",
"name": "test",
"host_config": {"env_id": "local"},
"packs": [],
"bundles": [],
"revisions": [],
"traffic_splits": [],
"messaging_endpoints": [],
"extensions": [],
"revocation": {},
"retention": {},
"health": {}
},
"starting_lifecycle": "staged"
}))
}
#[test]
fn warm_revision_happy_path() {
let body = sample_transition_response("ready");
let (_mock, store) = happy_store(200, &body);
let result = store.warm_revision(
&env_id(),
WarmRevisionPayload {
revision_id: RevisionId::new(),
health_gate: Ok(()),
expected_lifecycle: RevisionLifecycle::Staged,
},
idem(),
);
assert!(result.is_ok());
let outcome = result.unwrap();
assert_eq!(outcome.revision.lifecycle, RevisionLifecycle::Ready);
assert_eq!(outcome.starting_lifecycle, RevisionLifecycle::Staged);
}
#[test]
fn drain_revision_happy_path() {
let body = sample_transition_response("draining");
let (_mock, store) = happy_store(200, &body);
let result = store.drain_revision(&env_id(), RevisionId::new(), idem());
assert!(result.is_ok());
}
#[test]
fn archive_revision_happy_path() {
let body = sample_transition_response("archived");
let (_mock, store) = happy_store(200, &body);
let result = store.archive_revision(&env_id(), RevisionId::new(), idem());
assert!(result.is_ok());
}
fn sample_bundle_deployment() -> String {
wrap_mutation(serde_json::json!({
"schema": "greentic.bundle-deployment.v1",
"deployment_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
"env_id": "local",
"bundle_id": "fast2flow",
"customer_id": "local-dev",
"status": "active",
"current_revisions": [],
"route_binding": {
"hosts": ["fast2flow.local"],
"path_prefixes": [],
"tenant_selector": {"tenant": "default", "team": "default"}
},
"revenue_share": [{"party_id": "greentic", "basis_points": 10000}],
"revenue_policy_ref": "revenue.json",
"created_at": "2026-06-09T12:00:00Z",
"authorization_ref": "auth.json",
"config_overrides": {}
}))
}
#[test]
fn add_bundle_happy_path() {
let body = sample_bundle_deployment();
let (_mock, store) = happy_store(201, &body);
let result = store.add_bundle(
&env_id(),
AddBundlePayload {
bundle_id: BundleId::new("fast2flow"),
customer_id: greentic_deploy_spec::CustomerId::new("local-dev"),
revenue_share: Vec::new(),
route_binding: None,
authorization_ref: None,
config_overrides: std::collections::BTreeMap::new(),
},
idem(),
);
assert!(result.is_ok());
}
#[test]
fn update_bundle_happy_path() {
let body = sample_bundle_deployment();
let (_mock, store) = happy_store(200, &body);
let result = store.update_bundle(
&env_id(),
UpdateBundlePayload {
deployment_id: DeploymentId::new(),
status: Some(greentic_deploy_spec::BundleDeploymentStatus::Active),
route_binding: None,
revenue_share: None,
config_overrides: None,
},
idem(),
);
assert!(result.is_ok());
}
#[test]
fn remove_bundle_happy_path() {
let domain = serde_json::json!({
"deployment": {
"schema": "greentic.bundle-deployment.v1",
"deployment_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
"env_id": "local",
"bundle_id": "fast2flow",
"customer_id": "local-dev",
"status": "active",
"current_revisions": [],
"route_binding": {
"hosts": ["fast2flow.local"],
"path_prefixes": [],
"tenant_selector": {"tenant": "default", "team": "default"}
},
"revenue_share": [{"party_id": "greentic", "basis_points": 10000}],
"revenue_policy_ref": "revenue.json",
"created_at": "2026-06-09T12:00:00Z",
"authorization_ref": "auth.json",
"config_overrides": {}
},
"pruned_revision_ids": []
});
let body = wrap_mutation(domain);
let (_mock, store) = happy_store(200, &body);
let result = store.remove_bundle(&env_id(), DeploymentId::new(), idem());
assert!(result.is_ok());
assert!(result.unwrap().pruned_revision_ids.is_empty());
}
fn sample_pack_binding_json() -> String {
serde_json::json!({
"slot": "messaging",
"kind": "greentic.messaging@0.5.0",
"pack_ref": "greentic-messaging",
"generation": 1
})
.to_string()
}
fn sample_pack_binding() -> String {
wrap_mutation(serde_json::from_str(&sample_pack_binding_json()).unwrap())
}
fn sample_binding_generation_response(binding_json: &str, generation: u64) -> String {
let domain: serde_json::Value = serde_json::from_str(&format!(
r#"{{"binding": {binding_json}, "generation": {generation}}}"#
))
.unwrap();
wrap_mutation(domain)
}
#[test]
fn add_pack_binding_happy_path() {
let body = sample_pack_binding();
let (_mock, store) = happy_store(201, &body);
let binding: EnvPackBinding = serde_json::from_str(&sample_pack_binding_json()).unwrap();
let result = store.add_pack_binding(&env_id(), binding, idem());
assert!(result.is_ok());
}
#[test]
fn update_pack_binding_happy_path() {
let binding_json = sample_pack_binding_json();
let body = sample_binding_generation_response(&binding_json, 2);
let (_mock, store) = happy_store(200, &body);
let binding: EnvPackBinding = serde_json::from_str(&binding_json).unwrap();
let result =
store.update_pack_binding(&env_id(), CapabilitySlot::Messaging, binding, idem());
assert!(result.is_ok());
let (_, generation) = result.unwrap();
assert_eq!(generation, 2);
}
#[test]
fn remove_pack_binding_happy_path() {
let binding_json = sample_pack_binding_json();
let body = sample_binding_generation_response(&binding_json, 3);
let (_mock, store) = happy_store(200, &body);
let result = store.remove_pack_binding(&env_id(), CapabilitySlot::Messaging, idem());
assert!(result.is_ok());
}
#[test]
fn rollback_pack_binding_happy_path() {
let binding_json = sample_pack_binding_json();
let body = sample_binding_generation_response(&binding_json, 4);
let (_mock, store) = happy_store(200, &body);
let result = store.rollback_pack_binding(&env_id(), CapabilitySlot::Messaging, idem());
assert!(result.is_ok());
}
fn sample_extension_binding_json() -> String {
serde_json::json!({
"kind": "greentic.memory-chronicle@0.1.0",
"pack_ref": "greentic-chronicle",
"generation": 1
})
.to_string()
}
fn sample_extension_binding() -> String {
wrap_mutation(serde_json::from_str(&sample_extension_binding_json()).unwrap())
}
#[test]
fn add_extension_binding_happy_path() {
let body = sample_extension_binding();
let (_mock, store) = happy_store(201, &body);
let binding: ExtensionBinding =
serde_json::from_str(&sample_extension_binding_json()).unwrap();
let result = store.add_extension_binding(&env_id(), binding, idem());
assert!(result.is_ok());
}
#[test]
fn update_extension_binding_happy_path() {
let ext_json = sample_extension_binding_json();
let body = sample_binding_generation_response(&ext_json, 2);
let (_mock, store) = happy_store(200, &body);
let binding: ExtensionBinding = serde_json::from_str(&ext_json).unwrap();
let key = ExtensionKey::new("capability/memory/long-term", None);
let result = store.update_extension_binding(&env_id(), key, binding, idem());
assert!(result.is_ok());
let (_, generation) = result.unwrap();
assert_eq!(generation, 2);
}
#[test]
fn remove_extension_binding_happy_path() {
let ext_json = sample_extension_binding_json();
let body = sample_binding_generation_response(&ext_json, 3);
let (_mock, store) = happy_store(200, &body);
let key = ExtensionKey::new("capability/memory/long-term", None);
let result = store.remove_extension_binding(&env_id(), key, idem());
assert!(result.is_ok());
}
#[test]
fn rollback_extension_binding_happy_path() {
let ext_json = sample_extension_binding_json();
let body = sample_binding_generation_response(&ext_json, 4);
let (_mock, store) = happy_store(200, &body);
let key = ExtensionKey::new("capability/memory/long-term", None);
let result = store.rollback_extension_binding(&env_id(), key, idem());
assert!(result.is_ok());
}
fn sample_traffic_split() -> serde_json::Value {
serde_json::json!({
"schema": "greentic.traffic-split.v1",
"env_id": "local",
"deployment_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
"bundle_id": "fast2flow",
"generation": 2,
"entries": [],
"updated_at": "2026-06-09T12:00:00Z",
"updated_by": "tester",
"idempotency_key": "01JABC000000000000000000ZZ",
"authorization_ref": "auth.json"
})
}
#[test]
fn set_traffic_split_happy_path() {
let domain = serde_json::json!({
"split": sample_traffic_split(),
"previous_generation": 1,
"new_generation": 2,
"environment": sample_env_domain()
});
let body = wrap_mutation(domain);
let (_mock, store) = happy_store(200, &body);
let result = store.set_traffic_split(
&env_id(),
SetTrafficSplitPayload {
deployment_id: DeploymentId::new(),
entries: Vec::new(),
updated_by: "tester".to_string(),
authorization_ref: None,
},
idem(),
);
assert!(result.is_ok());
let outcome = result.unwrap();
assert_eq!(outcome.previous_generation, Some(1));
assert_eq!(outcome.new_generation, Some(2));
assert_eq!(outcome.environment.environment_id, env_id());
}
#[test]
fn rollback_traffic_split_happy_path() {
let domain = serde_json::json!({
"restored": sample_traffic_split(),
"previous_generation": 2,
"new_generation": 3,
"environment": sample_env_domain()
});
let body = wrap_mutation(domain);
let (_mock, store) = happy_store(200, &body);
let result = store.rollback_traffic_split(&env_id(), DeploymentId::new(), idem());
assert!(result.is_ok());
}
fn sample_messaging_endpoint() -> String {
wrap_mutation(serde_json::json!({
"schema": "greentic.messaging-endpoint.v1",
"env_id": "local",
"endpoint_id": "01JTKW5B4W4Q5Y1CQW93F7S5VH",
"provider_id": "tg-bot",
"provider_type": "telegram",
"display_name": "Telegram Bot",
"secret_refs": [],
"linked_bundles": [],
"generation": 0,
"created_at": "2026-06-09T12:00:00Z",
"updated_at": "2026-06-09T12:00:00Z",
"updated_by": "tester"
}))
}
#[test]
fn add_messaging_endpoint_happy_path() {
let body = sample_messaging_endpoint();
let (_mock, store) = happy_store(201, &body);
let result = store.add_messaging_endpoint(
&env_id(),
AddMessagingEndpointPayload {
provider_id: "tg-bot".to_string(),
provider_type: "telegram".to_string(),
display_name: "Telegram Bot".to_string(),
secret_refs: Vec::new(),
updated_by: "tester".to_string(),
},
idem(),
);
assert!(result.is_ok());
}
#[test]
fn link_messaging_bundle_happy_path() {
let body = sample_messaging_endpoint();
let (_mock, store) = happy_store(200, &body);
let result = store.link_messaging_bundle(
&env_id(),
MessagingEndpointId::new(),
BundleId::new("fast2flow"),
"tester".to_string(),
idem(),
);
assert!(result.is_ok());
}
#[test]
fn unlink_messaging_bundle_happy_path() {
let body = sample_messaging_endpoint();
let (_mock, store) = happy_store(200, &body);
let result = store.unlink_messaging_bundle(
&env_id(),
MessagingEndpointId::new(),
BundleId::new("fast2flow"),
"tester".to_string(),
idem(),
);
assert!(result.is_ok());
}
#[test]
fn set_messaging_welcome_flow_happy_path() {
let body = sample_messaging_endpoint();
let (_mock, store) = happy_store(200, &body);
let result = store.set_messaging_welcome_flow(
&env_id(),
SetMessagingWelcomeFlowPayload {
endpoint_id: MessagingEndpointId::new(),
bundle_id: BundleId::new("fast2flow"),
pack_id: PackId::new("greentic-messaging"),
flow_id: "welcome".to_string(),
updated_by: "tester".to_string(),
},
idem(),
);
assert!(result.is_ok());
}
#[test]
fn remove_messaging_endpoint_happy_path() {
let eid = MessagingEndpointId::new();
let body = wrap_mutation(serde_json::json!(eid.to_string()));
let (_mock, store) = happy_store(200, &body);
let result = store.remove_messaging_endpoint(&env_id(), eid);
assert!(result.is_ok());
}
#[test]
fn rotate_messaging_webhook_secret_happy_path() {
let body = sample_messaging_endpoint();
let (_mock, store) = happy_store(200, &body);
let result = store.rotate_messaging_webhook_secret(
&env_id(),
MessagingEndpointId::new(),
"tester".to_string(),
idem(),
);
assert!(result.is_ok());
}
fn sample_trust_root_seed() -> String {
wrap_mutation(serde_json::json!({
"key_id": "op-key-1",
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMFkw...\n-----END PUBLIC KEY-----",
"trusted_key_count": 1
}))
}
#[test]
fn bootstrap_trust_root_happy_path() {
let body = sample_trust_root_seed();
let (_mock, store) = happy_store(201, &body);
let result = store.bootstrap_trust_root(&env_id());
assert!(result.is_ok());
let seed = result.unwrap();
assert_eq!(seed.key_id, "op-key-1");
assert_eq!(seed.trusted_key_count, 1);
}
#[test]
fn seed_trust_root_if_absent_when_seeded() {
let body = sample_trust_root_seed();
let (_mock, store) = happy_store(200, &body);
let result = store.seed_trust_root_if_absent(&env_id());
assert!(result.is_ok());
assert!(result.unwrap().is_some());
}
#[test]
fn seed_trust_root_if_absent_when_already_exists() {
let body = wrap_mutation(serde_json::Value::Null);
let (_mock, store) = happy_store(200, &body);
let result = store.seed_trust_root_if_absent(&env_id());
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn add_trusted_key_happy_path() {
let domain = serde_json::json!({
"added_key_id": "external-key-1",
"trusted_key_count": 2
});
let body = wrap_mutation(domain);
let (_mock, store) = happy_store(201, &body);
let result = store.add_trusted_key(
&env_id(),
"external-key-1".to_string(),
"PEM-DATA".to_string(),
idem(),
);
assert!(result.is_ok());
let outcome = result.unwrap();
assert_eq!(outcome.added_key_id, "external-key-1");
assert_eq!(outcome.trusted_key_count, 2);
}
#[test]
fn remove_trusted_key_happy_path() {
let domain = serde_json::json!({
"removed_key_id": "external-key-1",
"removed_public_key_pem": "PEM-DATA",
"trusted_key_count": 1
});
let body = wrap_mutation(domain);
let (_mock, store) = happy_store(200, &body);
let result = store.remove_trusted_key(&env_id(), "external-key-1".to_string(), idem());
assert!(result.is_ok());
let outcome = result.unwrap();
assert_eq!(outcome.removed_key_id, "external-key-1");
assert_eq!(outcome.removed_public_key_pem, Some("PEM-DATA".to_string()));
}
#[test]
fn bearer_auth_sends_authorization_header() {
let body = sample_trust_root_seed();
let check = Arc::new(|_req_line: &str, headers: &str, _body: &[u8]| {
assert!(
headers.contains("Authorization: Bearer my-secret-token"),
"expected Bearer header in: {headers}"
);
});
let mock = start_mock(vec![(200, &body)], Some(check));
let store = mock_store(mock.addr, AuthMethod::Bearer("my-secret-token".to_string()));
let _ = store.bootstrap_trust_root(&env_id());
}
#[test]
fn idempotency_key_header_is_sent() {
let body = sample_revision_response();
let check = Arc::new(|_req_line: &str, headers: &str, _body: &[u8]| {
assert!(
headers.contains("Idempotency-Key: 01JABC000000000000000000ZZ"),
"expected Idempotency-Key header in: {headers}"
);
});
let mock = start_mock(vec![(201, &body)], Some(check));
let store = mock_store(mock.addr, AuthMethod::None);
let _ = store.stage_revision(
&env_id(),
StageRevisionPayload {
revision_id: RevisionId::new(),
deployment_id: DeploymentId::new(),
bundle_digest: "sha256:00".to_string(),
pack_list: Vec::new(),
pack_list_lock_ref: PathBuf::new(),
pack_config_refs: Vec::new(),
config_digest: "sha256:00".to_string(),
signature_sidecar_ref: PathBuf::from("rev.sig"),
drain_seconds: 30,
},
idem(),
);
}
#[test]
fn error_404_maps_to_not_found() {
let err_body = serde_json::json!({"kind": "not-found"});
let (_mock, store) = happy_store(404, &err_body.to_string());
let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
assert!(matches!(result, Err(StoreError::NotFound(_))));
}
#[test]
fn error_409_maps_to_conflict() {
let err_body = serde_json::json!({
"kind": "idempotency-conflict",
"reason": "key reused"
});
let (_mock, store) = happy_store(409, &err_body.to_string());
let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
assert!(matches!(result, Err(StoreError::Conflict(_))));
}
#[test]
fn error_500_maps_to_conflict_server() {
let err_body = serde_json::json!({
"kind": "internal",
"message": "disk full"
});
let (_mock, store) = happy_store(500, &err_body.to_string());
let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
match result {
Err(StoreError::Conflict(msg)) => {
assert!(
msg.contains("server:"),
"expected 'server:' prefix, got: {msg}"
);
}
other => panic!("expected Conflict, got {other:?}"),
}
}
#[test]
fn error_403_maps_to_unauthorized() {
let err_body = serde_json::json!({
"kind": "unauthorized",
"policy": "rbac-v1",
"reason": "insufficient permissions"
});
let (_mock, store) = happy_store(403, &err_body.to_string());
let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
match result {
Err(StoreError::Unauthorized { policy, reason }) => {
assert_eq!(policy, "rbac-v1");
assert_eq!(reason, "insufficient permissions");
}
other => panic!("expected Unauthorized, got {other:?}"),
}
}
#[test]
fn error_403_without_a8_body_maps_to_unauthorized() {
let (_mock, store) = happy_store(403, "access denied by gateway");
let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
match result {
Err(StoreError::Unauthorized { policy, reason }) => {
assert_eq!(policy, "remote");
assert_eq!(reason, "access denied by gateway");
}
other => panic!("expected Unauthorized, got {other:?}"),
}
}
#[test]
fn error_501_maps_to_not_yet_implemented() {
let err_body = serde_json::json!({
"kind": "not-yet-implemented",
"detail": "backup/restore lands in PR-4"
});
let (_mock, store) = happy_store(501, &err_body.to_string());
let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
match result {
Err(StoreError::NotYetImplemented(detail)) => {
assert_eq!(detail, "backup/restore lands in PR-4");
}
other => panic!("expected NotYetImplemented, got {other:?}"),
}
}
#[test]
fn transport_error_maps_to_conflict() {
let store =
HttpEnvironmentStore::new(Url::parse("http://127.0.0.1:1").unwrap(), AuthMethod::None)
.unwrap();
let result = store.update_environment(&env_id(), UpdateEnvironmentPayload::default());
match result {
Err(StoreError::Conflict(msg)) => {
assert!(
msg.starts_with("transport:"),
"expected 'transport:' prefix, got: {msg}"
);
}
other => panic!("expected Conflict(transport:...), got {other:?}"),
}
}
#[test]
fn encode_segment_passes_through_alphanumeric() {
assert_eq!(encode_segment("local"), "local");
assert_eq!(
encode_segment("01JTKW5B4W4Q5Y1CQW93F7S5VH"),
"01JTKW5B4W4Q5Y1CQW93F7S5VH"
);
}
#[test]
fn encode_segment_escapes_slash() {
let encoded = encode_segment("foo/bar");
assert!(
!encoded.contains('/'),
"slash must be escaped, got: {encoded}"
);
assert!(encoded.contains("%2F"));
}
#[test]
fn encode_segment_escapes_dot_dot() {
let encoded = encode_segment("..");
assert!(
!encoded.contains(".."),
"dots must be escaped, got: {encoded}"
);
assert!(encoded.contains("%2E"));
}
#[test]
fn encode_segment_escapes_query() {
let encoded = encode_segment("id?foo=bar");
assert!(
!encoded.contains('?'),
"query marker must be escaped, got: {encoded}"
);
}
#[test]
fn encode_segment_escapes_fragment() {
let encoded = encode_segment("id#frag");
assert!(
!encoded.contains('#'),
"fragment marker must be escaped, got: {encoded}"
);
}
#[test]
fn key_id_with_slash_reaches_escaped_path() {
let body = wrap_mutation(serde_json::json!({
"removed_key_id": "a/b",
"removed_public_key_pem": "PEM",
"trusted_key_count": 0
}));
let check = Arc::new(|req_line: &str, _headers: &str, _body: &[u8]| {
assert!(
req_line.contains("a%2Fb"),
"expected escaped slash in path, got: {req_line}"
);
});
let mock = start_mock(vec![(200, &body)], Some(check));
let store = mock_store(mock.addr, AuthMethod::None);
let _ = store.remove_trusted_key(&env_id(), "a/b".to_string(), idem());
}
#[test]
fn mutation_envelope_with_extra_metadata_is_accepted() {
let domain = serde_json::json!({
"schema": "greentic.environment.v1",
"environment_id": "local",
"name": "test-envelope",
"host_config": {"env_id": "local"},
"packs": [],
"bundles": [],
"revisions": [],
"traffic_splits": [],
"messaging_endpoints": [],
"extensions": [],
"revocation": {},
"retention": {},
"health": {}
});
let body = wrap_mutation(domain);
let (_mock, store) = happy_store(201, &body);
let result = store.create_environment(
&env_id(),
"test-envelope".to_string(),
EnvironmentHostConfig {
env_id: env_id(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
},
);
assert!(result.is_ok());
assert_eq!(result.unwrap().name, "test-envelope");
}
#[test]
fn bootstrap_trust_root_sends_idempotency_key() {
let body = sample_trust_root_seed();
let check = Arc::new(|_req_line: &str, headers: &str, _body: &[u8]| {
assert!(
headers.contains("Idempotency-Key:"),
"expected Idempotency-Key header in: {headers}"
);
let key = headers
.lines()
.find(|l| l.starts_with("Idempotency-Key:"))
.and_then(|l| l.split(':').nth(1))
.map(|v| v.trim())
.unwrap_or("");
assert!(!key.is_empty(), "Idempotency-Key must be non-empty");
});
let mock = start_mock(vec![(201, &body)], Some(check));
let store = mock_store(mock.addr, AuthMethod::None);
let _ = store.bootstrap_trust_root(&env_id());
}
#[test]
fn create_environment_sends_idempotency_key() {
let domain = serde_json::json!({
"schema": "greentic.environment.v1",
"environment_id": "local",
"name": "test",
"host_config": {"env_id": "local"},
"packs": [],
"bundles": [],
"revisions": [],
"traffic_splits": [],
"messaging_endpoints": [],
"extensions": [],
"revocation": {},
"retention": {},
"health": {}
});
let body = wrap_mutation(domain);
let check = Arc::new(|_req_line: &str, headers: &str, _body: &[u8]| {
assert!(
headers.contains("Idempotency-Key:"),
"expected Idempotency-Key header for create_environment: {headers}"
);
});
let mock = start_mock(vec![(201, &body)], Some(check));
let store = mock_store(mock.addr, AuthMethod::None);
let _ = store.create_environment(
&env_id(),
"test".to_string(),
EnvironmentHostConfig {
env_id: env_id(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
},
);
}
#[test]
fn minted_keys_are_unique_per_call() {
let key1 = mint_idempotency_key();
let key2 = mint_idempotency_key();
assert_ne!(key1, key2, "minted keys must differ per call");
}
#[test]
fn bearer_over_http_non_loopback_is_rejected() {
let result = HttpEnvironmentStore::new(
Url::parse("http://192.0.2.1:8080").unwrap(),
AuthMethod::Bearer("token".to_string()),
);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, ConstructionError::InsecureTransport(_)),
"expected InsecureTransport, got: {err:?}"
);
}
#[test]
fn bearer_over_http_loopback_is_allowed() {
let result = HttpEnvironmentStore::new(
Url::parse("http://127.0.0.1:8080").unwrap(),
AuthMethod::Bearer("token".to_string()),
);
assert!(result.is_ok());
}
#[test]
fn bearer_over_https_is_allowed() {
let result = HttpEnvironmentStore::new(
Url::parse("https://example.com").unwrap(),
AuthMethod::Bearer("token".to_string()),
);
assert!(result.is_ok());
}
#[test]
fn no_auth_over_http_is_allowed() {
let result = HttpEnvironmentStore::new(
Url::parse("http://192.0.2.1:8080").unwrap(),
AuthMethod::None,
);
assert!(result.is_ok());
}
#[allow(dead_code)]
fn _http_store_is_trait_object(store: &HttpEnvironmentStore) {
let _dyn: &dyn EnvironmentMutations = store;
}
}