1use chrono::{DateTime, Utc};
79use serde::{Deserialize, Serialize};
80use thiserror::Error;
81
82use crate::environment::Environment;
83use crate::ids::{BundleId, MessagingEndpointId, PackId};
84use crate::messaging_endpoint::{MessagingEndpoint, WelcomeFlowRef};
85use crate::refs::SecretRef;
86use crate::remote::IdempotencyKey;
87use crate::version::SchemaVersion;
88use greentic_types::EnvId;
89
90#[derive(Debug, Clone, PartialEq, Eq, Error)]
98pub enum MessagingError {
99 #[error(
102 "idempotency key `{key}` already used to add `{provider_type}`/`{provider_id}` \
103 in env `{env_id}`; pass a fresh key"
104 )]
105 IdempotencyKeyReuse {
106 key: String,
107 provider_type: String,
108 provider_id: String,
109 env_id: EnvId,
110 },
111 #[error(
113 "messaging endpoint with provider_type=`{provider_type}` provider_id=`{provider_id}` \
114 already exists in env `{env_id}`"
115 )]
116 EndpointAlreadyExists {
117 provider_type: String,
118 provider_id: String,
119 env_id: EnvId,
120 },
121 #[error("messaging endpoint `{endpoint_id}` not found in env `{env_id}`")]
122 EndpointNotFound {
123 endpoint_id: MessagingEndpointId,
124 env_id: EnvId,
125 },
126 #[error("bundle `{bundle_id}` is not deployed in env `{env_id}`")]
127 BundleNotDeployed { bundle_id: BundleId, env_id: EnvId },
128 #[error(
129 "cannot unlink bundle `{bundle_id}` from endpoint `{endpoint_id}` while it owns the \
130 welcome_flow; clear the welcome_flow first via `set-welcome-flow` to a different \
131 linked bundle, or `remove` the endpoint"
132 )]
133 WelcomeFlowOwned {
134 bundle_id: BundleId,
135 endpoint_id: MessagingEndpointId,
136 },
137 #[error(
138 "welcome_flow bundle `{bundle_id}` is not linked to endpoint `{endpoint_id}`; \
139 link it first via `link-bundle`"
140 )]
141 BundleNotLinked {
142 bundle_id: BundleId,
143 endpoint_id: MessagingEndpointId,
144 },
145 #[error(
146 "welcome_flow.pack_id `{pack_id}` does not appear in any current revision of \
147 bundle `{bundle_id}` (known: [{}])", known.join(", ")
148 )]
149 WelcomePackUnknown {
150 pack_id: String,
151 bundle_id: BundleId,
152 known: Vec<String>,
153 },
154 #[error("secret_ref `{raw}`: {message}")]
155 InvalidSecretRef { raw: String, message: String },
156 #[error("{0}")]
161 SecretProvision(String),
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct AddMessagingEndpointPayload {
178 pub provider_id: String,
179 pub provider_type: String,
180 pub display_name: String,
181 #[serde(default)]
184 pub secret_refs: Vec<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub webhook_secret_ref: Option<String>,
194 pub updated_by: String,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct MessagingBundleLinkPayload {
202 pub bundle_id: BundleId,
203 pub updated_by: String,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct SetMessagingWelcomeFlowPayload {
212 pub endpoint_id: MessagingEndpointId,
213 pub bundle_id: BundleId,
214 pub pack_id: PackId,
215 pub flow_id: String,
216 pub updated_by: String,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct RotateWebhookSecretPayload {
223 pub updated_by: String,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub webhook_secret_ref: Option<String>,
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub struct MessagingApplied {
241 pub index: usize,
243 pub mutated: bool,
246}
247
248const OP_ADD: &str = "add";
255const OP_LINK_BUNDLE: &str = "link-bundle";
256const OP_UNLINK_BUNDLE: &str = "unlink-bundle";
257const OP_SET_WELCOME_FLOW: &str = "set-welcome-flow";
258const OP_ROTATE_WEBHOOK_SECRET: &str = "rotate-webhook-secret";
259
260pub fn is_telegram_class(provider_type: &str) -> bool {
270 let rest = provider_type
271 .strip_prefix("messaging.")
272 .unwrap_or(provider_type);
273 rest == "telegram" || rest.starts_with("telegram.")
274}
275
276fn format_idem_writer(updated_by: &str, op: &str, idempotency_key: &str) -> String {
282 format!("{updated_by}#idem={op}:{idempotency_key}")
283}
284
285fn idem_suffix(op: &str, idempotency_key: &str) -> String {
293 format!("#idem={op}:{idempotency_key}")
294}
295
296fn carries_idem_key(endpoint: &MessagingEndpoint, idem_suffix: &str) -> bool {
297 endpoint.updated_by.ends_with(idem_suffix)
298}
299
300fn stamp_mutation(
302 endpoint: &mut MessagingEndpoint,
303 updated_by: &str,
304 op: &str,
305 idempotency_key: &str,
306 now: DateTime<Utc>,
307) {
308 endpoint.generation = endpoint.generation.saturating_add(1);
309 endpoint.updated_at = now;
310 endpoint.updated_by = format_idem_writer(updated_by, op, idempotency_key);
311}
312
313fn find_endpoint_idx(
316 env: &Environment,
317 endpoint_id: MessagingEndpointId,
318) -> Result<usize, MessagingError> {
319 env.messaging_endpoints
320 .iter()
321 .position(|e| e.endpoint_id == endpoint_id)
322 .ok_or_else(|| MessagingError::EndpointNotFound {
323 endpoint_id,
324 env_id: env.environment_id.clone(),
325 })
326}
327
328fn validate_welcome_pack_id(
333 env: &Environment,
334 bundle_id: &BundleId,
335 pack_id: &str,
336) -> Result<(), MessagingError> {
337 let bundles: Vec<_> = env
338 .bundles
339 .iter()
340 .filter(|b| b.bundle_id == *bundle_id)
341 .collect();
342 if bundles.is_empty() {
343 return Ok(());
344 }
345 let mut saw_any_pack = false;
346 let mut known_packs: Vec<String> = Vec::new();
347 for bundle in bundles {
348 for rev_id in &bundle.current_revisions {
349 let Some(rev) = env.revisions.iter().find(|r| r.revision_id == *rev_id) else {
350 continue;
351 };
352 for entry in &rev.pack_list {
353 saw_any_pack = true;
354 if entry.pack_id.as_str() == pack_id {
355 return Ok(());
356 }
357 known_packs.push(entry.pack_id.as_str().to_string());
358 }
359 }
360 }
361 if !saw_any_pack {
362 return Ok(());
363 }
364 known_packs.sort();
365 known_packs.dedup();
366 Err(MessagingError::WelcomePackUnknown {
367 pack_id: pack_id.to_string(),
368 bundle_id: bundle_id.clone(),
369 known: known_packs,
370 })
371}
372
373pub fn add_messaging_endpoint(
389 env: &mut Environment,
390 payload: AddMessagingEndpointPayload,
391 endpoint_id: MessagingEndpointId,
392 idempotency_key: &IdempotencyKey,
393 now: DateTime<Utc>,
394 provision: impl FnOnce(Option<&SecretRef>) -> Result<SecretRef, MessagingError>,
395) -> Result<MessagingApplied, MessagingError> {
396 let suffix = idem_suffix(OP_ADD, idempotency_key.as_str());
397 if let Some(idx) = env
404 .messaging_endpoints
405 .iter()
406 .position(|e| carries_idem_key(e, &suffix))
407 {
408 let prev = &env.messaging_endpoints[idx];
409 if prev.provider_type == payload.provider_type && prev.provider_id == payload.provider_id {
410 return Ok(MessagingApplied {
411 index: idx,
412 mutated: false,
413 });
414 }
415 return Err(MessagingError::IdempotencyKeyReuse {
416 key: idempotency_key.as_str().to_string(),
417 provider_type: prev.provider_type.clone(),
418 provider_id: prev.provider_id.clone(),
419 env_id: env.environment_id.clone(),
420 });
421 }
422 if env
423 .messaging_endpoints
424 .iter()
425 .any(|e| e.provider_type == payload.provider_type && e.provider_id == payload.provider_id)
426 {
427 return Err(MessagingError::EndpointAlreadyExists {
428 provider_type: payload.provider_type,
429 provider_id: payload.provider_id,
430 env_id: env.environment_id.clone(),
431 });
432 }
433 let secret_refs: Vec<SecretRef> = payload
436 .secret_refs
437 .iter()
438 .map(|r| {
439 SecretRef::try_new(r).map_err(|e| MessagingError::InvalidSecretRef {
440 raw: r.clone(),
441 message: e.to_string(),
442 })
443 })
444 .collect::<Result<_, _>>()?;
445 let webhook_secret_ref = match (
446 is_telegram_class(&payload.provider_type),
447 payload.webhook_secret_ref.as_deref(),
448 ) {
449 (is_telegram, Some(raw)) => Some(validate_caller_webhook_ref(is_telegram, raw)?),
455 (true, None) => Some(provision(None)?),
458 (false, None) => None,
459 };
460 env.messaging_endpoints.push(MessagingEndpoint {
461 schema: SchemaVersion::new(SchemaVersion::MESSAGING_ENDPOINT_V1),
462 env_id: env.environment_id.clone(),
463 endpoint_id,
464 provider_id: payload.provider_id,
465 provider_type: payload.provider_type,
466 display_name: payload.display_name,
467 secret_refs,
468 webhook_secret_ref,
469 linked_bundles: Vec::new(),
470 welcome_flow: None,
471 generation: 0,
472 created_at: now,
473 updated_at: now,
474 updated_by: format_idem_writer(&payload.updated_by, OP_ADD, idempotency_key.as_str()),
475 });
476 Ok(MessagingApplied {
477 index: env.messaging_endpoints.len() - 1,
478 mutated: true,
479 })
480}
481
482pub fn link_messaging_bundle(
486 env: &mut Environment,
487 endpoint_id: MessagingEndpointId,
488 bundle_id: BundleId,
489 updated_by: &str,
490 idempotency_key: &IdempotencyKey,
491 now: DateTime<Utc>,
492) -> Result<MessagingApplied, MessagingError> {
493 let idx = find_endpoint_idx(env, endpoint_id)?;
494 if !env.bundles.iter().any(|b| b.bundle_id == bundle_id) {
495 return Err(MessagingError::BundleNotDeployed {
496 bundle_id,
497 env_id: env.environment_id.clone(),
498 });
499 }
500 if env.messaging_endpoints[idx]
501 .linked_bundles
502 .contains(&bundle_id)
503 {
504 return Ok(MessagingApplied {
505 index: idx,
506 mutated: false,
507 });
508 }
509 env.messaging_endpoints[idx].linked_bundles.push(bundle_id);
510 stamp_mutation(
511 &mut env.messaging_endpoints[idx],
512 updated_by,
513 OP_LINK_BUNDLE,
514 idempotency_key.as_str(),
515 now,
516 );
517 Ok(MessagingApplied {
518 index: idx,
519 mutated: true,
520 })
521}
522
523pub fn unlink_messaging_bundle(
528 env: &mut Environment,
529 endpoint_id: MessagingEndpointId,
530 bundle_id: BundleId,
531 updated_by: &str,
532 idempotency_key: &IdempotencyKey,
533 now: DateTime<Utc>,
534) -> Result<MessagingApplied, MessagingError> {
535 let idx = find_endpoint_idx(env, endpoint_id)?;
536 let Some(bidx) = env.messaging_endpoints[idx]
537 .linked_bundles
538 .iter()
539 .position(|b| b == &bundle_id)
540 else {
541 return Ok(MessagingApplied {
543 index: idx,
544 mutated: false,
545 });
546 };
547 if let Some(welcome) = &env.messaging_endpoints[idx].welcome_flow
548 && welcome.bundle_id == bundle_id
549 {
550 return Err(MessagingError::WelcomeFlowOwned {
551 bundle_id,
552 endpoint_id,
553 });
554 }
555 env.messaging_endpoints[idx].linked_bundles.remove(bidx);
556 stamp_mutation(
557 &mut env.messaging_endpoints[idx],
558 updated_by,
559 OP_UNLINK_BUNDLE,
560 idempotency_key.as_str(),
561 now,
562 );
563 Ok(MessagingApplied {
564 index: idx,
565 mutated: true,
566 })
567}
568
569pub fn set_messaging_welcome_flow(
575 env: &mut Environment,
576 payload: SetMessagingWelcomeFlowPayload,
577 idempotency_key: &IdempotencyKey,
578 now: DateTime<Utc>,
579) -> Result<MessagingApplied, MessagingError> {
580 let idx = find_endpoint_idx(env, payload.endpoint_id)?;
581 if !env.messaging_endpoints[idx]
582 .linked_bundles
583 .contains(&payload.bundle_id)
584 {
585 return Err(MessagingError::BundleNotLinked {
586 bundle_id: payload.bundle_id,
587 endpoint_id: payload.endpoint_id,
588 });
589 }
590 validate_welcome_pack_id(env, &payload.bundle_id, payload.pack_id.as_str())?;
591 let new_welcome = WelcomeFlowRef {
592 bundle_id: payload.bundle_id,
593 pack_id: payload.pack_id,
594 flow_id: payload.flow_id,
595 };
596 if env.messaging_endpoints[idx].welcome_flow.as_ref() == Some(&new_welcome) {
597 return Ok(MessagingApplied {
598 index: idx,
599 mutated: false,
600 });
601 }
602 env.messaging_endpoints[idx].welcome_flow = Some(new_welcome);
603 stamp_mutation(
604 &mut env.messaging_endpoints[idx],
605 &payload.updated_by,
606 OP_SET_WELCOME_FLOW,
607 idempotency_key.as_str(),
608 now,
609 );
610 Ok(MessagingApplied {
611 index: idx,
612 mutated: true,
613 })
614}
615
616pub fn remove_messaging_endpoint(env: &mut Environment, endpoint_id: MessagingEndpointId) -> bool {
620 match env
621 .messaging_endpoints
622 .iter()
623 .position(|e| e.endpoint_id == endpoint_id)
624 {
625 Some(idx) => {
626 env.messaging_endpoints.remove(idx);
627 true
628 }
629 None => false,
630 }
631}
632
633fn validate_caller_webhook_ref(is_telegram: bool, raw: &str) -> Result<SecretRef, MessagingError> {
638 if !is_telegram {
639 return Err(MessagingError::InvalidSecretRef {
640 raw: raw.to_string(),
641 message: "webhook_secret_ref is only valid for telegram-class providers".to_string(),
642 });
643 }
644 SecretRef::try_new(raw).map_err(|e| MessagingError::InvalidSecretRef {
645 raw: raw.to_string(),
646 message: e.to_string(),
647 })
648}
649
650pub fn rotate_messaging_webhook_secret(
661 env: &mut Environment,
662 endpoint_id: MessagingEndpointId,
663 updated_by: &str,
664 idempotency_key: &IdempotencyKey,
665 now: DateTime<Utc>,
666 new_ref: Option<&str>,
667 provision: impl FnOnce(Option<&SecretRef>) -> Result<SecretRef, MessagingError>,
668) -> Result<MessagingApplied, MessagingError> {
669 let idx = find_endpoint_idx(env, endpoint_id)?;
670 let suffix = idem_suffix(OP_ROTATE_WEBHOOK_SECRET, idempotency_key.as_str());
671 if carries_idem_key(&env.messaging_endpoints[idx], &suffix) {
672 return Ok(MessagingApplied {
673 index: idx,
674 mutated: false,
675 });
676 }
677 let secret_ref = match new_ref {
678 Some(raw) => validate_caller_webhook_ref(
679 is_telegram_class(&env.messaging_endpoints[idx].provider_type),
680 raw,
681 )?,
682 None => provision(env.messaging_endpoints[idx].webhook_secret_ref.as_ref())?,
683 };
684 env.messaging_endpoints[idx].webhook_secret_ref = Some(secret_ref);
685 stamp_mutation(
686 &mut env.messaging_endpoints[idx],
687 updated_by,
688 OP_ROTATE_WEBHOOK_SECRET,
689 idempotency_key.as_str(),
690 now,
691 );
692 Ok(MessagingApplied {
693 index: idx,
694 mutated: true,
695 })
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use crate::bundle_deployment::{
702 BundleDeployment, BundleDeploymentStatus, RouteBinding, TenantSelector,
703 };
704 use crate::engine::fresh_environment;
705 use crate::environment::EnvironmentHostConfig;
706 use crate::ids::{CustomerId, RevisionId};
707 use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
708 use crate::revision::{PackListEntry, Revision, RevisionLifecycle};
709 use std::path::PathBuf;
710
711 fn env_id() -> EnvId {
712 EnvId::try_from("local").unwrap()
713 }
714
715 fn minimal_env() -> Environment {
716 fresh_environment(
717 &env_id(),
718 "Local".to_string(),
719 EnvironmentHostConfig {
720 env_id: env_id(),
721 region: None,
722 tenant_org_id: None,
723 listen_addr: None,
724 public_base_url: None,
725 gui_enabled: None,
726 },
727 RevocationConfig::default(),
728 RetentionPolicy::default(),
729 HealthStatus::default(),
730 )
731 }
732
733 fn fixed_now() -> DateTime<Utc> {
734 "2026-06-12T00:00:00Z".parse().unwrap()
735 }
736
737 fn key(raw: &str) -> IdempotencyKey {
738 IdempotencyKey::new(raw).unwrap()
739 }
740
741 fn add_payload(provider_type: &str, provider_id: &str) -> AddMessagingEndpointPayload {
742 AddMessagingEndpointPayload {
743 provider_id: provider_id.to_string(),
744 provider_type: provider_type.to_string(),
745 display_name: format!("{provider_type} {provider_id}"),
746 secret_refs: Vec::new(),
747 webhook_secret_ref: None,
748 updated_by: "tester".to_string(),
749 }
750 }
751
752 fn add_payload_with_webhook_ref(
753 provider_type: &str,
754 provider_id: &str,
755 webhook_secret_ref: &str,
756 ) -> AddMessagingEndpointPayload {
757 AddMessagingEndpointPayload {
758 webhook_secret_ref: Some(webhook_secret_ref.to_string()),
759 ..add_payload(provider_type, provider_id)
760 }
761 }
762
763 fn no_provision(_: Option<&SecretRef>) -> Result<SecretRef, MessagingError> {
765 panic!("provision must not be called on this path");
766 }
767
768 fn fixed_ref() -> SecretRef {
769 SecretRef::try_new("secret://local/default/_/messaging-x/webhook_secret").unwrap()
770 }
771
772 fn deployed_bundle(env: &mut Environment, bundle: &str) -> BundleId {
773 let bundle_id = BundleId::new(bundle);
774 env.bundles.push(BundleDeployment {
775 schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
776 deployment_id: crate::ids::DeploymentId::new(),
777 env_id: env_id(),
778 bundle_id: bundle_id.clone(),
779 customer_id: CustomerId::new("cust"),
780 status: BundleDeploymentStatus::Active,
781 current_revisions: Vec::new(),
782 route_binding: RouteBinding {
783 hosts: Vec::new(),
784 path_prefixes: Vec::new(),
785 tenant_selector: TenantSelector {
786 tenant: "default".to_string(),
787 team: "default".to_string(),
788 },
789 },
790 revenue_share: Vec::new(),
791 revenue_policy_ref: PathBuf::new(),
792 usage: None,
793 created_at: fixed_now(),
794 authorization_ref: PathBuf::from("auth.json"),
795 config_overrides: Default::default(),
796 });
797 bundle_id
798 }
799
800 fn added(env: &mut Environment, provider_type: &str, provider_id: &str, k: &str) -> usize {
801 add_messaging_endpoint(
802 env,
803 add_payload(provider_type, provider_id),
804 MessagingEndpointId::new(),
805 &key(k),
806 fixed_now(),
807 no_provision,
808 )
809 .expect("add")
810 .index
811 }
812
813 #[test]
816 fn add_non_telegram_skips_provision_and_pushes() {
817 let mut env = minimal_env();
818 let applied = add_messaging_endpoint(
819 &mut env,
820 add_payload("teams", "legal"),
821 MessagingEndpointId::new(),
822 &key("k1"),
823 fixed_now(),
824 no_provision,
825 )
826 .unwrap();
827 assert!(applied.mutated);
828 let ep = &env.messaging_endpoints[applied.index];
829 assert_eq!(ep.provider_type, "teams");
830 assert_eq!(ep.updated_by, "tester#idem=add:k1");
831 assert_eq!(ep.generation, 0);
832 assert!(ep.webhook_secret_ref.is_none());
833 }
834
835 #[test]
836 fn add_telegram_class_provisions_with_no_existing_ref() {
837 let mut env = minimal_env();
838 let applied = add_messaging_endpoint(
839 &mut env,
840 add_payload("telegram", "bot-a"),
841 MessagingEndpointId::new(),
842 &key("k1"),
843 fixed_now(),
844 |existing| {
845 assert!(existing.is_none(), "fresh endpoint has no existing ref");
846 Ok(fixed_ref())
847 },
848 )
849 .unwrap();
850 assert_eq!(
851 env.messaging_endpoints[applied.index].webhook_secret_ref,
852 Some(fixed_ref())
853 );
854 }
855
856 #[test]
857 fn add_telegram_class_with_supplied_ref_uses_it_without_provision() {
858 let mut env = minimal_env();
859 let supplied = "secret://local/default/_/messaging-byo/webhook_secret";
860 let applied = add_messaging_endpoint(
863 &mut env,
864 add_payload_with_webhook_ref("telegram", "bot-a", supplied),
865 MessagingEndpointId::new(),
866 &key("k1"),
867 fixed_now(),
868 no_provision,
869 )
870 .unwrap();
871 assert!(applied.mutated);
872 assert_eq!(
873 env.messaging_endpoints[applied.index]
874 .webhook_secret_ref
875 .as_ref()
876 .map(|r| r.as_str()),
877 Some(supplied)
878 );
879 }
880
881 #[test]
882 fn add_telegram_class_with_malformed_supplied_ref_is_rejected() {
883 let mut env = minimal_env();
884 let err = add_messaging_endpoint(
885 &mut env,
886 add_payload_with_webhook_ref("telegram", "bot-a", "not-a-secret-uri"),
887 MessagingEndpointId::new(),
888 &key("k1"),
889 fixed_now(),
890 no_provision,
891 )
892 .unwrap_err();
893 assert!(matches!(err, MessagingError::InvalidSecretRef { .. }));
894 assert!(
895 env.messaging_endpoints.is_empty(),
896 "a rejected add must not push"
897 );
898 }
899
900 #[test]
901 fn add_non_telegram_with_supplied_ref_is_rejected() {
902 let mut env = minimal_env();
903 let err = add_messaging_endpoint(
904 &mut env,
905 add_payload_with_webhook_ref(
906 "teams",
907 "legal",
908 "secret://local/default/_/messaging-x/webhook_secret",
909 ),
910 MessagingEndpointId::new(),
911 &key("k1"),
912 fixed_now(),
913 no_provision,
914 )
915 .unwrap_err();
916 match err {
917 MessagingError::InvalidSecretRef { ref message, .. } => {
918 assert!(
919 message.contains("only valid for telegram-class"),
920 "got {err:?}"
921 );
922 }
923 other => panic!("expected InvalidSecretRef, got {other:?}"),
924 }
925 assert!(env.messaging_endpoints.is_empty());
926 }
927
928 #[test]
929 fn add_same_key_same_identity_replays_without_mutation() {
930 let mut env = minimal_env();
931 let idx = added(&mut env, "teams", "legal", "k-replay");
932 let before = env.clone();
933 let applied = add_messaging_endpoint(
934 &mut env,
935 add_payload("teams", "legal"),
936 MessagingEndpointId::new(),
937 &key("k-replay"),
938 fixed_now(),
939 no_provision,
940 )
941 .unwrap();
942 assert_eq!(applied.index, idx);
943 assert!(!applied.mutated);
944 assert_eq!(env, before, "replay must leave the env untouched");
945 }
946
947 #[test]
948 fn add_same_key_different_identity_is_key_reuse() {
949 let mut env = minimal_env();
950 added(&mut env, "teams", "legal", "k-shared");
951 let err = add_messaging_endpoint(
952 &mut env,
953 add_payload("slack", "ops"),
954 MessagingEndpointId::new(),
955 &key("k-shared"),
956 fixed_now(),
957 no_provision,
958 )
959 .unwrap_err();
960 assert!(matches!(err, MessagingError::IdempotencyKeyReuse { .. }));
961 assert_eq!(
962 err.to_string(),
963 "idempotency key `k-shared` already used to add `teams`/`legal` in env `local`; \
964 pass a fresh key"
965 );
966 }
967
968 #[test]
969 fn add_duplicate_identity_rejected() {
970 let mut env = minimal_env();
971 added(&mut env, "teams", "legal", "k1");
972 let err = add_messaging_endpoint(
973 &mut env,
974 add_payload("teams", "legal"),
975 MessagingEndpointId::new(),
976 &key("k2"),
977 fixed_now(),
978 no_provision,
979 )
980 .unwrap_err();
981 assert_eq!(
982 err.to_string(),
983 "messaging endpoint with provider_type=`teams` provider_id=`legal` already \
984 exists in env `local`"
985 );
986 }
987
988 #[test]
989 fn add_invalid_secret_ref_rejected_before_provision() {
990 let mut env = minimal_env();
991 let mut payload = add_payload("telegram", "bot-a");
992 payload.secret_refs = vec!["not-a-ref".to_string()];
993 let err = add_messaging_endpoint(
995 &mut env,
996 payload,
997 MessagingEndpointId::new(),
998 &key("k1"),
999 fixed_now(),
1000 no_provision,
1001 )
1002 .unwrap_err();
1003 assert!(matches!(err, MessagingError::InvalidSecretRef { .. }));
1004 assert!(env.messaging_endpoints.is_empty());
1005 }
1006
1007 #[test]
1008 fn add_provision_failure_leaves_env_untouched() {
1009 let mut env = minimal_env();
1010 let err = add_messaging_endpoint(
1011 &mut env,
1012 add_payload("telegram", "bot-a"),
1013 MessagingEndpointId::new(),
1014 &key("k1"),
1015 fixed_now(),
1016 |_| Err(MessagingError::SecretProvision("sink down".to_string())),
1017 )
1018 .unwrap_err();
1019 assert_eq!(err.to_string(), "sink down");
1020 assert!(env.messaging_endpoints.is_empty());
1021 }
1022
1023 #[test]
1026 fn link_appends_and_stamps() {
1027 let mut env = minimal_env();
1028 let bundle_id = deployed_bundle(&mut env, "legal-pack");
1029 let idx = added(&mut env, "teams", "legal", "k1");
1030 let eid = env.messaging_endpoints[idx].endpoint_id;
1031 let applied = link_messaging_bundle(
1032 &mut env,
1033 eid,
1034 bundle_id.clone(),
1035 "op",
1036 &key("k2"),
1037 fixed_now(),
1038 )
1039 .unwrap();
1040 assert!(applied.mutated);
1041 let ep = &env.messaging_endpoints[applied.index];
1042 assert_eq!(ep.linked_bundles, vec![bundle_id]);
1043 assert_eq!(ep.generation, 1);
1044 assert_eq!(ep.updated_by, "op#idem=link-bundle:k2");
1045 }
1046
1047 #[test]
1048 fn link_unknown_endpoint_not_found() {
1049 let mut env = minimal_env();
1050 deployed_bundle(&mut env, "legal-pack");
1051 let ghost = MessagingEndpointId::new();
1052 let err = link_messaging_bundle(
1053 &mut env,
1054 ghost,
1055 BundleId::new("legal-pack"),
1056 "op",
1057 &key("k1"),
1058 fixed_now(),
1059 )
1060 .unwrap_err();
1061 assert_eq!(
1062 err.to_string(),
1063 format!("messaging endpoint `{ghost}` not found in env `local`")
1064 );
1065 }
1066
1067 #[test]
1068 fn link_undeployed_bundle_rejected() {
1069 let mut env = minimal_env();
1070 let idx = added(&mut env, "teams", "legal", "k1");
1071 let eid = env.messaging_endpoints[idx].endpoint_id;
1072 let err = link_messaging_bundle(
1073 &mut env,
1074 eid,
1075 BundleId::new("ghost-pack"),
1076 "op",
1077 &key("k2"),
1078 fixed_now(),
1079 )
1080 .unwrap_err();
1081 assert_eq!(
1082 err.to_string(),
1083 "bundle `ghost-pack` is not deployed in env `local`"
1084 );
1085 }
1086
1087 #[test]
1088 fn link_already_linked_is_noop() {
1089 let mut env = minimal_env();
1090 let bundle_id = deployed_bundle(&mut env, "legal-pack");
1091 let idx = added(&mut env, "teams", "legal", "k1");
1092 let eid = env.messaging_endpoints[idx].endpoint_id;
1093 link_messaging_bundle(
1094 &mut env,
1095 eid,
1096 bundle_id.clone(),
1097 "op",
1098 &key("k2"),
1099 fixed_now(),
1100 )
1101 .unwrap();
1102 let before = env.clone();
1103 let applied =
1104 link_messaging_bundle(&mut env, eid, bundle_id, "op", &key("k3"), fixed_now()).unwrap();
1105 assert!(!applied.mutated);
1106 assert_eq!(env, before);
1107 }
1108
1109 #[test]
1110 fn unlink_welcome_owner_rejected() {
1111 let mut env = minimal_env();
1112 let bundle_id = deployed_bundle(&mut env, "legal-pack");
1113 let idx = added(&mut env, "teams", "legal", "k1");
1114 let eid = env.messaging_endpoints[idx].endpoint_id;
1115 link_messaging_bundle(
1116 &mut env,
1117 eid,
1118 bundle_id.clone(),
1119 "op",
1120 &key("k2"),
1121 fixed_now(),
1122 )
1123 .unwrap();
1124 set_messaging_welcome_flow(
1125 &mut env,
1126 SetMessagingWelcomeFlowPayload {
1127 endpoint_id: eid,
1128 bundle_id: bundle_id.clone(),
1129 pack_id: PackId::new("welcome-pack"),
1130 flow_id: "hello".to_string(),
1131 updated_by: "op".to_string(),
1132 },
1133 &key("k3"),
1134 fixed_now(),
1135 )
1136 .unwrap();
1137 let err = unlink_messaging_bundle(&mut env, eid, bundle_id, "op", &key("k4"), fixed_now())
1138 .unwrap_err();
1139 assert!(matches!(err, MessagingError::WelcomeFlowOwned { .. }));
1140 }
1141
1142 #[test]
1143 fn unlink_not_linked_is_noop() {
1144 let mut env = minimal_env();
1145 let bundle_id = deployed_bundle(&mut env, "legal-pack");
1146 let idx = added(&mut env, "teams", "legal", "k1");
1147 let eid = env.messaging_endpoints[idx].endpoint_id;
1148 let before = env.clone();
1149 let applied =
1150 unlink_messaging_bundle(&mut env, eid, bundle_id, "op", &key("k2"), fixed_now())
1151 .unwrap();
1152 assert!(!applied.mutated);
1153 assert_eq!(env, before);
1154 }
1155
1156 #[test]
1157 fn unlink_removes_and_stamps() {
1158 let mut env = minimal_env();
1159 let bundle_id = deployed_bundle(&mut env, "legal-pack");
1160 let idx = added(&mut env, "teams", "legal", "k1");
1161 let eid = env.messaging_endpoints[idx].endpoint_id;
1162 link_messaging_bundle(
1163 &mut env,
1164 eid,
1165 bundle_id.clone(),
1166 "op",
1167 &key("k2"),
1168 fixed_now(),
1169 )
1170 .unwrap();
1171 let applied =
1172 unlink_messaging_bundle(&mut env, eid, bundle_id, "op", &key("k3"), fixed_now())
1173 .unwrap();
1174 assert!(applied.mutated);
1175 let ep = &env.messaging_endpoints[applied.index];
1176 assert!(ep.linked_bundles.is_empty());
1177 assert_eq!(ep.generation, 2);
1178 }
1179
1180 #[test]
1183 fn welcome_flow_requires_linked_bundle() {
1184 let mut env = minimal_env();
1185 deployed_bundle(&mut env, "legal-pack");
1186 let idx = added(&mut env, "teams", "legal", "k1");
1187 let eid = env.messaging_endpoints[idx].endpoint_id;
1188 let err = set_messaging_welcome_flow(
1189 &mut env,
1190 SetMessagingWelcomeFlowPayload {
1191 endpoint_id: eid,
1192 bundle_id: BundleId::new("legal-pack"),
1193 pack_id: PackId::new("welcome-pack"),
1194 flow_id: "hello".to_string(),
1195 updated_by: "op".to_string(),
1196 },
1197 &key("k2"),
1198 fixed_now(),
1199 )
1200 .unwrap_err();
1201 assert!(matches!(err, MessagingError::BundleNotLinked { .. }));
1202 }
1203
1204 #[test]
1205 fn welcome_flow_unknown_pack_rejected_with_known_list() {
1206 let mut env = minimal_env();
1207 let bundle_id = deployed_bundle(&mut env, "legal-pack");
1208 let rev_id = RevisionId::new();
1210 env.bundles[0].current_revisions.push(rev_id);
1211 env.revisions.push(Revision {
1212 schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
1213 revision_id: rev_id,
1214 env_id: env_id(),
1215 bundle_id: bundle_id.clone(),
1216 deployment_id: env.bundles[0].deployment_id,
1217 sequence: 1,
1218 created_at: fixed_now(),
1219 bundle_digest: "sha256:deadbeef".to_string(),
1220 bundle_source_uri: None,
1221 pack_list: vec![PackListEntry {
1222 pack_id: PackId::new("known-pack"),
1223 version: "1.0.0".parse().unwrap(),
1224 digest: "sha256:cafe".to_string(),
1225 source_uri: None,
1226 }],
1227 pack_list_lock_ref: PathBuf::from("pack-list.lock"),
1228 pack_config_refs: Vec::new(),
1229 config_digest: "sha256:cafe".to_string(),
1230 signature_sidecar_ref: PathBuf::from("rev.sig"),
1231 lifecycle: RevisionLifecycle::Ready,
1232 staged_at: None,
1233 warmed_at: None,
1234 drain_seconds: 0,
1235 abort_metrics: Vec::new(),
1236 });
1237 let idx = added(&mut env, "teams", "legal", "k1");
1238 let eid = env.messaging_endpoints[idx].endpoint_id;
1239 link_messaging_bundle(
1240 &mut env,
1241 eid,
1242 bundle_id.clone(),
1243 "op",
1244 &key("k2"),
1245 fixed_now(),
1246 )
1247 .unwrap();
1248 let err = set_messaging_welcome_flow(
1249 &mut env,
1250 SetMessagingWelcomeFlowPayload {
1251 endpoint_id: eid,
1252 bundle_id,
1253 pack_id: PackId::new("ghost-pack"),
1254 flow_id: "hello".to_string(),
1255 updated_by: "op".to_string(),
1256 },
1257 &key("k3"),
1258 fixed_now(),
1259 )
1260 .unwrap_err();
1261 assert_eq!(
1262 err.to_string(),
1263 "welcome_flow.pack_id `ghost-pack` does not appear in any current revision of \
1264 bundle `legal-pack` (known: [known-pack])"
1265 );
1266 }
1267
1268 #[test]
1269 fn welcome_flow_same_ref_is_noop() {
1270 let mut env = minimal_env();
1271 let bundle_id = deployed_bundle(&mut env, "legal-pack");
1272 let idx = added(&mut env, "teams", "legal", "k1");
1273 let eid = env.messaging_endpoints[idx].endpoint_id;
1274 link_messaging_bundle(
1275 &mut env,
1276 eid,
1277 bundle_id.clone(),
1278 "op",
1279 &key("k2"),
1280 fixed_now(),
1281 )
1282 .unwrap();
1283 let payload = SetMessagingWelcomeFlowPayload {
1284 endpoint_id: eid,
1285 bundle_id,
1286 pack_id: PackId::new("welcome-pack"),
1287 flow_id: "hello".to_string(),
1288 updated_by: "op".to_string(),
1289 };
1290 let first =
1291 set_messaging_welcome_flow(&mut env, payload.clone(), &key("k3"), fixed_now()).unwrap();
1292 assert!(first.mutated);
1293 let before = env.clone();
1294 let second =
1295 set_messaging_welcome_flow(&mut env, payload, &key("k4"), fixed_now()).unwrap();
1296 assert!(!second.mutated);
1297 assert_eq!(env, before);
1298 }
1299
1300 #[test]
1303 fn remove_present_then_absent() {
1304 let mut env = minimal_env();
1305 let idx = added(&mut env, "teams", "legal", "k1");
1306 let eid = env.messaging_endpoints[idx].endpoint_id;
1307 assert!(remove_messaging_endpoint(&mut env, eid));
1308 assert!(env.messaging_endpoints.is_empty());
1309 assert!(!remove_messaging_endpoint(&mut env, eid));
1310 }
1311
1312 #[test]
1315 fn rotate_passes_existing_ref_and_stamps() {
1316 let mut env = minimal_env();
1317 let applied = add_messaging_endpoint(
1318 &mut env,
1319 add_payload("telegram", "bot-a"),
1320 MessagingEndpointId::new(),
1321 &key("k1"),
1322 fixed_now(),
1323 |_| Ok(fixed_ref()),
1324 )
1325 .unwrap();
1326 let eid = env.messaging_endpoints[applied.index].endpoint_id;
1327 let rotated = rotate_messaging_webhook_secret(
1328 &mut env,
1329 eid,
1330 "op",
1331 &key("k2"),
1332 fixed_now(),
1333 None,
1334 |existing| {
1335 assert_eq!(existing, Some(&fixed_ref()), "existing ref must be reused");
1336 Ok(fixed_ref())
1337 },
1338 )
1339 .unwrap();
1340 assert!(rotated.mutated);
1341 assert_eq!(env.messaging_endpoints[rotated.index].generation, 1);
1342 }
1343
1344 #[test]
1345 fn rotate_same_key_replay_skips_provision() {
1346 let mut env = minimal_env();
1347 let applied = add_messaging_endpoint(
1348 &mut env,
1349 add_payload("telegram", "bot-a"),
1350 MessagingEndpointId::new(),
1351 &key("k1"),
1352 fixed_now(),
1353 |_| Ok(fixed_ref()),
1354 )
1355 .unwrap();
1356 let eid = env.messaging_endpoints[applied.index].endpoint_id;
1357 rotate_messaging_webhook_secret(
1359 &mut env,
1360 eid,
1361 "op",
1362 &key("k-rotate"),
1363 fixed_now(),
1364 None,
1365 |existing| {
1366 assert_eq!(existing, Some(&fixed_ref()));
1367 Ok(fixed_ref())
1368 },
1369 )
1370 .unwrap();
1371 let before = env.clone();
1373 let rotated = rotate_messaging_webhook_secret(
1374 &mut env,
1375 eid,
1376 "op",
1377 &key("k-rotate"),
1378 fixed_now(),
1379 None,
1380 no_provision,
1381 )
1382 .unwrap();
1383 assert!(!rotated.mutated);
1384 assert_eq!(env, before);
1385 }
1386
1387 #[test]
1388 fn rotate_unknown_endpoint_not_found() {
1389 let mut env = minimal_env();
1390 let ghost = MessagingEndpointId::new();
1391 let err = rotate_messaging_webhook_secret(
1392 &mut env,
1393 ghost,
1394 "op",
1395 &key("k1"),
1396 fixed_now(),
1397 None,
1398 no_provision,
1399 )
1400 .unwrap_err();
1401 assert!(matches!(err, MessagingError::EndpointNotFound { .. }));
1402 }
1403
1404 #[test]
1405 fn rotate_new_ref_validates_and_stamps_without_provision() {
1406 let mut env = minimal_env();
1407 let applied = add_messaging_endpoint(
1408 &mut env,
1409 add_payload("telegram", "bot-a"),
1410 MessagingEndpointId::new(),
1411 &key("k1"),
1412 fixed_now(),
1413 |_| Ok(fixed_ref()),
1414 )
1415 .unwrap();
1416 let eid = env.messaging_endpoints[applied.index].endpoint_id;
1417 let rotated = rotate_messaging_webhook_secret(
1420 &mut env,
1421 eid,
1422 "op",
1423 &key("k2"),
1424 fixed_now(),
1425 Some("secret://local/acme/_/messaging-tg/webhook_secret"),
1426 no_provision,
1427 )
1428 .unwrap();
1429 assert!(rotated.mutated);
1430 assert_eq!(
1431 env.messaging_endpoints[rotated.index].webhook_secret_ref,
1432 Some(SecretRef::try_new("secret://local/acme/_/messaging-tg/webhook_secret").unwrap())
1433 );
1434 }
1435
1436 #[test]
1437 fn rotate_new_ref_on_non_telegram_rejected() {
1438 let mut env = minimal_env();
1439 let applied = add_messaging_endpoint(
1440 &mut env,
1441 add_payload("teams", "legal-bot"),
1442 MessagingEndpointId::new(),
1443 &key("k1"),
1444 fixed_now(),
1445 no_provision,
1446 )
1447 .unwrap();
1448 let eid = env.messaging_endpoints[applied.index].endpoint_id;
1449 let err = rotate_messaging_webhook_secret(
1450 &mut env,
1451 eid,
1452 "op",
1453 &key("k2"),
1454 fixed_now(),
1455 Some("secret://local/acme/_/messaging-x/webhook_secret"),
1456 no_provision,
1457 )
1458 .unwrap_err();
1459 assert!(matches!(err, MessagingError::InvalidSecretRef { .. }));
1460 }
1461
1462 #[test]
1467 fn rotate_with_add_key_does_not_replay() {
1468 let mut env = minimal_env();
1469 let applied = add_messaging_endpoint(
1470 &mut env,
1471 add_payload("telegram", "bot-a"),
1472 MessagingEndpointId::new(),
1473 &key("k-shared"),
1474 fixed_now(),
1475 |_| Ok(fixed_ref()),
1476 )
1477 .unwrap();
1478 assert!(applied.mutated);
1479 let eid = env.messaging_endpoints[applied.index].endpoint_id;
1480 let mut provision_called = false;
1482 let rotated = rotate_messaging_webhook_secret(
1483 &mut env,
1484 eid,
1485 "op",
1486 &key("k-shared"),
1487 fixed_now(),
1488 None,
1489 |existing| {
1490 provision_called = true;
1491 assert_eq!(existing, Some(&fixed_ref()));
1492 Ok(fixed_ref())
1493 },
1494 )
1495 .unwrap();
1496 assert!(
1497 provision_called,
1498 "provision must be called for a cross-op key"
1499 );
1500 assert!(rotated.mutated);
1501 assert_eq!(env.messaging_endpoints[rotated.index].generation, 1);
1502 }
1503
1504 #[test]
1509 fn add_with_link_stamped_key_proceeds_as_fresh_mutation() {
1510 let mut env = minimal_env();
1511 let bundle_id = deployed_bundle(&mut env, "legal-pack");
1512 let idx = added(&mut env, "teams", "legal", "k1");
1513 let eid = env.messaging_endpoints[idx].endpoint_id;
1514 link_messaging_bundle(
1516 &mut env,
1517 eid,
1518 bundle_id,
1519 "op",
1520 &key("k-shared"),
1521 fixed_now(),
1522 )
1523 .unwrap();
1524 let applied = add_messaging_endpoint(
1526 &mut env,
1527 add_payload("slack", "ops"),
1528 MessagingEndpointId::new(),
1529 &key("k-shared"),
1530 fixed_now(),
1531 no_provision,
1532 )
1533 .unwrap();
1534 assert!(applied.mutated);
1535 assert_eq!(
1536 env.messaging_endpoints[applied.index].provider_type,
1537 "slack"
1538 );
1539 }
1540
1541 #[test]
1544 fn telegram_class_is_strict_on_the_dot() {
1545 assert!(is_telegram_class("telegram"));
1546 assert!(is_telegram_class("telegram.bot"));
1547 assert!(is_telegram_class("messaging.telegram"));
1548 assert!(is_telegram_class("messaging.telegram.bot"));
1549 assert!(!is_telegram_class("telegrambot"));
1550 assert!(!is_telegram_class("messaging.telegrambot"));
1551 assert!(!is_telegram_class("teams"));
1552 }
1553
1554 #[test]
1561 fn add_payload_wire_encoding() {
1562 let payload = AddMessagingEndpointPayload {
1563 provider_id: "legal-bot".to_string(),
1564 provider_type: "teams".to_string(),
1565 display_name: "Legal".to_string(),
1566 secret_refs: vec!["secret://local/default/_/p/token".to_string()],
1567 webhook_secret_ref: None,
1568 updated_by: "op".to_string(),
1569 };
1570 let json = serde_json::to_value(&payload).unwrap();
1571 assert_eq!(
1574 json,
1575 serde_json::json!({
1576 "provider_id": "legal-bot",
1577 "provider_type": "teams",
1578 "display_name": "Legal",
1579 "secret_refs": ["secret://local/default/_/p/token"],
1580 "updated_by": "op",
1581 })
1582 );
1583 let back: AddMessagingEndpointPayload = serde_json::from_value(json).unwrap();
1584 assert_eq!(back, payload);
1585 }
1586
1587 #[test]
1588 fn add_payload_wire_encoding_with_webhook_ref() {
1589 let payload = AddMessagingEndpointPayload {
1590 provider_id: "tg-bot".to_string(),
1591 provider_type: "telegram".to_string(),
1592 display_name: "Bot".to_string(),
1593 secret_refs: Vec::new(),
1594 webhook_secret_ref: Some(
1595 "secret://local/default/_/messaging-byo/webhook_secret".to_string(),
1596 ),
1597 updated_by: "op".to_string(),
1598 };
1599 let json = serde_json::to_value(&payload).unwrap();
1600 assert_eq!(
1601 json["webhook_secret_ref"],
1602 "secret://local/default/_/messaging-byo/webhook_secret"
1603 );
1604 let back: AddMessagingEndpointPayload = serde_json::from_value(json).unwrap();
1605 assert_eq!(back, payload);
1606 }
1607
1608 #[test]
1609 fn rotate_payload_wire_encoding_carries_new_ref() {
1610 let payload = RotateWebhookSecretPayload {
1611 updated_by: "op".to_string(),
1612 webhook_secret_ref: Some(
1613 "secret://local/acme/_/messaging-tg/webhook_secret".to_string(),
1614 ),
1615 };
1616 let json = serde_json::to_value(&payload).unwrap();
1617 assert_eq!(
1618 json["webhook_secret_ref"],
1619 "secret://local/acme/_/messaging-tg/webhook_secret"
1620 );
1621 let back: RotateWebhookSecretPayload = serde_json::from_value(json).unwrap();
1622 assert_eq!(back, payload);
1623 }
1624
1625 #[test]
1626 fn link_payload_wire_encoding() {
1627 let payload = MessagingBundleLinkPayload {
1628 bundle_id: BundleId::new("legal-pack"),
1629 updated_by: "op".to_string(),
1630 };
1631 assert_eq!(
1632 serde_json::to_value(&payload).unwrap(),
1633 serde_json::json!({"bundle_id": "legal-pack", "updated_by": "op"})
1634 );
1635 }
1636
1637 #[test]
1638 fn welcome_flow_payload_wire_encoding() {
1639 let eid = MessagingEndpointId::new();
1640 let payload = SetMessagingWelcomeFlowPayload {
1641 endpoint_id: eid,
1642 bundle_id: BundleId::new("legal-pack"),
1643 pack_id: PackId::new("welcome-pack"),
1644 flow_id: "hello".to_string(),
1645 updated_by: "op".to_string(),
1646 };
1647 assert_eq!(
1648 serde_json::to_value(&payload).unwrap(),
1649 serde_json::json!({
1650 "endpoint_id": eid.to_string(),
1651 "bundle_id": "legal-pack",
1652 "pack_id": "welcome-pack",
1653 "flow_id": "hello",
1654 "updated_by": "op",
1655 })
1656 );
1657 }
1658
1659 #[test]
1660 fn rotate_payload_wire_encoding() {
1661 let payload = RotateWebhookSecretPayload {
1662 updated_by: "op".to_string(),
1663 webhook_secret_ref: None,
1664 };
1665 assert_eq!(
1668 serde_json::to_value(&payload).unwrap(),
1669 serde_json::json!({"updated_by": "op"})
1670 );
1671 }
1672}