1use greentic_deploy_spec::{
19 BundleId, EnvId, MessagingEndpoint, MessagingEndpointId, PackId, SecretRef,
20};
21use serde::{Deserialize, Serialize};
22use serde_json::{Value, json};
23use std::str::FromStr;
24
25use crate::environment::{
26 AddMessagingEndpointPayload, EnvironmentReads, LocalFsStore, SetMessagingWelcomeFlowPayload,
27};
28
29use super::dispatch::{
30 MessagingEndpointAddArgs, MessagingEndpointLinkBundleArgs, MessagingEndpointRemoveArgs,
31 MessagingEndpointSetWelcomeFlowArgs,
32};
33use super::{
34 AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
35 resolve_idempotency_key,
36};
37use std::path::PathBuf;
38
39const NOUN: &str = "messaging.endpoint";
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct EndpointAddPayload {
45 pub environment_id: String,
46 pub provider_id: String,
47 pub provider_type: String,
48 pub display_name: String,
49 #[serde(default)]
50 pub secret_refs: Vec<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub webhook_secret_ref: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub idempotency_key: Option<String>,
61 pub updated_by: String,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct EndpointLinkBundlePayload {
66 pub environment_id: String,
67 pub endpoint_id: String,
68 pub bundle_id: String,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub idempotency_key: Option<String>,
73 pub updated_by: String,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct EndpointSetWelcomeFlowPayload {
78 pub environment_id: String,
79 pub endpoint_id: String,
80 pub bundle_id: String,
81 pub pack_id: String,
82 pub flow_id: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub idempotency_key: Option<String>,
87 pub updated_by: String,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct EndpointRotateWebhookSecretPayload {
92 pub environment_id: String,
93 pub endpoint_id: String,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub idempotency_key: Option<String>,
98 pub updated_by: String,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub webhook_secret_ref: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct EndpointRemovePayload {
112 pub environment_id: String,
113 pub endpoint_id: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub idempotency_key: Option<String>,
118 pub updated_by: String,
119}
120
121fn reject_inline_plus_answers(
142 has_inline: bool,
143 flags: &OpFlags,
144 verb: &'static str,
145) -> Result<(), OpError> {
146 if has_inline && flags.answers.is_some() {
147 return Err(OpError::InvalidArgument(format!(
148 "messaging.endpoint {verb}: inline flags and --answers are mutually exclusive; use one or the other"
149 )));
150 }
151 Ok(())
152}
153
154fn partial_inline_error(verb: &'static str, required: &str, missing: &[&str]) -> OpError {
157 OpError::InvalidArgument(format!(
158 "messaging.endpoint {verb}: inline-flag form requires {required}; missing: {}",
159 missing.join(", ")
160 ))
161}
162
163impl MessagingEndpointAddArgs {
164 fn has_inline_input(&self) -> bool {
166 self.env.is_some()
167 || self.provider_type.is_some()
168 || self.provider_id.is_some()
169 || self.display_name.is_some()
170 || !self.secret_ref.is_empty()
171 || self.webhook_secret_ref.is_some()
172 || self.idempotency_key.is_some()
173 || self.updated_by.is_some()
174 }
175
176 pub fn into_payload(
177 self,
178 verb: &'static str,
179 flags: &OpFlags,
180 ) -> Result<Option<EndpointAddPayload>, OpError> {
181 let has_inline = self.has_inline_input();
182 reject_inline_plus_answers(has_inline, flags, verb)?;
183 if !has_inline {
184 return Ok(None);
185 }
186 let mut missing = Vec::new();
187 if self.env.is_none() {
188 missing.push("--env");
189 }
190 if self.provider_type.is_none() {
191 missing.push("--provider-type");
192 }
193 if self.provider_id.is_none() {
194 missing.push("--provider-id");
195 }
196 if self.display_name.is_none() {
197 missing.push("--display-name");
198 }
199 if self.idempotency_key.is_none() {
200 missing.push("--idempotency-key");
201 }
202 if self.updated_by.is_none() {
203 missing.push("--updated-by");
204 }
205 if !missing.is_empty() {
206 return Err(partial_inline_error(
207 verb,
208 "--env, --provider-type, --provider-id, --display-name, --idempotency-key, --updated-by",
209 &missing,
210 ));
211 }
212 Ok(Some(EndpointAddPayload {
213 environment_id: self.env.unwrap(),
214 provider_id: self.provider_id.unwrap(),
215 provider_type: self.provider_type.unwrap(),
216 display_name: self.display_name.unwrap(),
217 secret_refs: self.secret_ref,
218 webhook_secret_ref: self.webhook_secret_ref,
219 idempotency_key: self.idempotency_key,
220 updated_by: self.updated_by.unwrap(),
221 }))
222 }
223}
224
225impl MessagingEndpointLinkBundleArgs {
226 fn has_inline_input(&self) -> bool {
228 self.env.is_some()
229 || self.endpoint_id.is_some()
230 || self.bundle_id.is_some()
231 || self.idempotency_key.is_some()
232 || self.updated_by.is_some()
233 }
234
235 pub fn into_payload(
236 self,
237 verb: &'static str,
238 flags: &OpFlags,
239 ) -> Result<Option<EndpointLinkBundlePayload>, OpError> {
240 let has_inline = self.has_inline_input();
241 reject_inline_plus_answers(has_inline, flags, verb)?;
242 if !has_inline {
243 return Ok(None);
244 }
245 let mut missing = Vec::new();
246 if self.env.is_none() {
247 missing.push("--env");
248 }
249 if self.endpoint_id.is_none() {
250 missing.push("--endpoint-id");
251 }
252 if self.bundle_id.is_none() {
253 missing.push("--bundle-id");
254 }
255 if self.idempotency_key.is_none() {
256 missing.push("--idempotency-key");
257 }
258 if self.updated_by.is_none() {
259 missing.push("--updated-by");
260 }
261 if !missing.is_empty() {
262 return Err(partial_inline_error(
263 verb,
264 "--env, --endpoint-id, --bundle-id, --idempotency-key, --updated-by",
265 &missing,
266 ));
267 }
268 Ok(Some(EndpointLinkBundlePayload {
269 environment_id: self.env.unwrap(),
270 endpoint_id: self.endpoint_id.unwrap(),
271 bundle_id: self.bundle_id.unwrap(),
272 idempotency_key: self.idempotency_key,
273 updated_by: self.updated_by.unwrap(),
274 }))
275 }
276}
277
278impl MessagingEndpointSetWelcomeFlowArgs {
279 fn has_inline_input(&self) -> bool {
281 self.env.is_some()
282 || self.endpoint_id.is_some()
283 || self.bundle_id.is_some()
284 || self.pack_id.is_some()
285 || self.flow_id.is_some()
286 || self.idempotency_key.is_some()
287 || self.updated_by.is_some()
288 }
289
290 pub fn into_payload(
291 self,
292 verb: &'static str,
293 flags: &OpFlags,
294 ) -> Result<Option<EndpointSetWelcomeFlowPayload>, OpError> {
295 let has_inline = self.has_inline_input();
296 reject_inline_plus_answers(has_inline, flags, verb)?;
297 if !has_inline {
298 return Ok(None);
299 }
300 let mut missing = Vec::new();
301 if self.env.is_none() {
302 missing.push("--env");
303 }
304 if self.endpoint_id.is_none() {
305 missing.push("--endpoint-id");
306 }
307 if self.bundle_id.is_none() {
308 missing.push("--bundle-id");
309 }
310 if self.pack_id.is_none() {
311 missing.push("--pack-id");
312 }
313 if self.flow_id.is_none() {
314 missing.push("--flow-id");
315 }
316 if self.idempotency_key.is_none() {
317 missing.push("--idempotency-key");
318 }
319 if self.updated_by.is_none() {
320 missing.push("--updated-by");
321 }
322 if !missing.is_empty() {
323 return Err(partial_inline_error(
324 verb,
325 "--env, --endpoint-id, --bundle-id, --pack-id, --flow-id, --idempotency-key, --updated-by",
326 &missing,
327 ));
328 }
329 Ok(Some(EndpointSetWelcomeFlowPayload {
330 environment_id: self.env.unwrap(),
331 endpoint_id: self.endpoint_id.unwrap(),
332 bundle_id: self.bundle_id.unwrap(),
333 pack_id: self.pack_id.unwrap(),
334 flow_id: self.flow_id.unwrap(),
335 idempotency_key: self.idempotency_key,
336 updated_by: self.updated_by.unwrap(),
337 }))
338 }
339}
340
341type ValidatedRemoveFields = (String, String, Option<String>, String);
346
347impl MessagingEndpointRemoveArgs {
348 fn has_inline_input(&self) -> bool {
350 self.env.is_some()
351 || self.endpoint_id.is_some()
352 || self.idempotency_key.is_some()
353 || self.updated_by.is_some()
354 }
355
356 fn validated_fields(
360 self,
361 verb: &'static str,
362 flags: &OpFlags,
363 ) -> Result<Option<ValidatedRemoveFields>, OpError> {
364 let has_inline = self.has_inline_input();
365 reject_inline_plus_answers(has_inline, flags, verb)?;
366 if !has_inline {
367 return Ok(None);
368 }
369 let mut missing = Vec::new();
370 if self.env.is_none() {
371 missing.push("--env");
372 }
373 if self.endpoint_id.is_none() {
374 missing.push("--endpoint-id");
375 }
376 if self.idempotency_key.is_none() {
377 missing.push("--idempotency-key");
378 }
379 if self.updated_by.is_none() {
380 missing.push("--updated-by");
381 }
382 if !missing.is_empty() {
383 return Err(partial_inline_error(
384 verb,
385 "--env, --endpoint-id, --idempotency-key, --updated-by",
386 &missing,
387 ));
388 }
389 Ok(Some((
390 self.env.unwrap(),
391 self.endpoint_id.unwrap(),
392 self.idempotency_key,
393 self.updated_by.unwrap(),
394 )))
395 }
396
397 pub fn into_remove_payload(
398 self,
399 verb: &'static str,
400 flags: &OpFlags,
401 ) -> Result<Option<EndpointRemovePayload>, OpError> {
402 Ok(self
403 .validated_fields(verb, flags)?
404 .map(|(env, eid, ikey, by)| EndpointRemovePayload {
405 environment_id: env,
406 endpoint_id: eid,
407 idempotency_key: ikey,
408 updated_by: by,
409 }))
410 }
411
412 pub fn into_rotate_payload(
413 self,
414 verb: &'static str,
415 flags: &OpFlags,
416 ) -> Result<Option<EndpointRotateWebhookSecretPayload>, OpError> {
417 Ok(self
418 .validated_fields(verb, flags)?
419 .map(|(env, eid, ikey, by)| EndpointRotateWebhookSecretPayload {
420 environment_id: env,
421 endpoint_id: eid,
422 idempotency_key: ikey,
423 updated_by: by,
424 webhook_secret_ref: None,
427 }))
428 }
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct EndpointSummary {
435 pub environment_id: String,
436 pub endpoint_id: String,
437 pub provider_type: String,
438 pub provider_id: String,
439 pub display_name: String,
440 pub linked_bundles: Vec<String>,
441 #[serde(skip_serializing_if = "Option::is_none")]
442 pub welcome_flow: Option<WelcomeFlowSummary>,
443 pub secret_refs: Vec<String>,
444 pub generation: u64,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct WelcomeFlowSummary {
449 pub bundle_id: String,
450 pub pack_id: String,
451 pub flow_id: String,
452}
453
454impl EndpointSummary {
455 pub(crate) fn from(env_id: &EnvId, ep: &MessagingEndpoint) -> Self {
456 Self {
457 environment_id: env_id.as_str().to_string(),
458 endpoint_id: ep.endpoint_id.to_string(),
459 provider_type: ep.provider_type.clone(),
460 provider_id: ep.provider_id.clone(),
461 display_name: ep.display_name.clone(),
462 linked_bundles: ep
463 .linked_bundles
464 .iter()
465 .map(|b| b.as_str().to_string())
466 .collect(),
467 welcome_flow: ep.welcome_flow.as_ref().map(|w| WelcomeFlowSummary {
468 bundle_id: w.bundle_id.as_str().to_string(),
469 pack_id: w.pack_id.as_str().to_string(),
470 flow_id: w.flow_id.clone(),
471 }),
472 secret_refs: ep
473 .secret_refs
474 .iter()
475 .map(|r| r.as_str().to_string())
476 .collect(),
477 generation: ep.generation,
478 }
479 }
480}
481
482pub fn add(
485 store: &LocalFsStore,
486 flags: &OpFlags,
487 payload: Option<EndpointAddPayload>,
488) -> Result<OpOutcome, OpError> {
489 if flags.schema_only {
490 return Ok(OpOutcome::new(NOUN, "add", add_schema()));
491 }
492 let payload = resolve_payload(flags, payload)?;
493 let env_id = parse_env_id(&payload.environment_id)?;
494 let provider_id = require_nonempty("provider_id", &payload.provider_id)?;
495 let provider_type = require_nonempty("provider_type", &payload.provider_type)?;
496 let display_name = require_nonempty("display_name", &payload.display_name)?;
497 let updated_by = require_nonempty("updated_by", &payload.updated_by)?;
498 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
499 let ctx = AuditCtx {
500 env_id: env_id.clone(),
501 noun: NOUN,
502 verb: "add",
503 target: json!({
504 "provider_id": provider_id,
505 "provider_type": provider_type,
506 }),
507 idempotency_key: Some(idempotency_key.as_str().to_string()),
508 };
509 audit_and_record(store, ctx, |committed| {
510 let ep = store
511 .add_messaging_endpoint(
512 &env_id,
513 AddMessagingEndpointPayload {
514 provider_id,
515 provider_type,
516 display_name,
517 secret_refs: payload.secret_refs,
518 webhook_secret_ref: payload.webhook_secret_ref,
519 updated_by,
520 },
521 idempotency_key,
522 )
523 .inspect_err(|err| {
524 if err.is_committed_after_save() {
525 committed.mark_committed();
526 }
527 })
528 .map_err(map_store_err_preserving_noun)?;
529 committed.mark_committed();
530 let summary = EndpointSummary::from(&env_id, &ep);
531 Ok((
532 OpOutcome::new(
533 NOUN,
534 "add",
535 serde_json::to_value(summary).expect("EndpointSummary is json-safe"),
536 ),
537 super::AuditGens::NONE,
538 ))
539 })
540}
541
542pub fn list(
543 store: &dyn EnvironmentReads,
544 flags: &OpFlags,
545 env_id: &str,
546) -> Result<OpOutcome, OpError> {
547 if flags.schema_only {
548 return Ok(OpOutcome::new(
549 NOUN,
550 "list",
551 json!({"input_schema": "env_id positional"}),
552 ));
553 }
554 let env_id = parse_env_id(env_id)?;
555 if !store.env_exists(&env_id)? {
556 return Err(OpError::NotFound(format!("environment `{env_id}`")));
557 }
558 let env = store.load_env(&env_id)?;
559 let endpoints: Vec<EndpointSummary> = env
560 .messaging_endpoints
561 .iter()
562 .map(|e| EndpointSummary::from(&env_id, e))
563 .collect();
564 Ok(OpOutcome::new(
565 NOUN,
566 "list",
567 json!({"environment_id": env_id.as_str(), "endpoints": endpoints}),
568 ))
569}
570
571pub fn show(
572 store: &dyn EnvironmentReads,
573 flags: &OpFlags,
574 env_id: &str,
575 endpoint_id: &str,
576) -> Result<OpOutcome, OpError> {
577 if flags.schema_only {
578 return Ok(OpOutcome::new(
579 NOUN,
580 "show",
581 json!({"input_schema": "env_id + endpoint_id positionals"}),
582 ));
583 }
584 let env_id = parse_env_id(env_id)?;
585 let endpoint_id = parse_endpoint_id(endpoint_id)?;
586 if !store.env_exists(&env_id)? {
587 return Err(OpError::NotFound(format!("environment `{env_id}`")));
588 }
589 let env = store.load_env(&env_id)?;
590 let endpoint = env
591 .messaging_endpoints
592 .iter()
593 .find(|e| e.endpoint_id == endpoint_id)
594 .ok_or_else(|| {
595 OpError::NotFound(format!(
596 "messaging endpoint `{endpoint_id}` not found in env `{env_id}`"
597 ))
598 })?;
599 Ok(OpOutcome::new(
600 NOUN,
601 "show",
602 serde_json::to_value(EndpointSummary::from(&env_id, endpoint))
603 .expect("EndpointSummary is json-safe"),
604 ))
605}
606
607pub fn link_bundle(
608 store: &LocalFsStore,
609 flags: &OpFlags,
610 payload: Option<EndpointLinkBundlePayload>,
611) -> Result<OpOutcome, OpError> {
612 if flags.schema_only {
613 return Ok(OpOutcome::new(NOUN, "link-bundle", link_bundle_schema()));
614 }
615 let payload = resolve_payload(flags, payload)?;
616 let env_id = parse_env_id(&payload.environment_id)?;
617 let endpoint_id = parse_endpoint_id(&payload.endpoint_id)?;
618 let bundle_id = parse_bundle_id(&payload.bundle_id)?;
619 let updated_by = require_nonempty("updated_by", &payload.updated_by)?;
620 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
621 let ctx = AuditCtx {
622 env_id: env_id.clone(),
623 noun: NOUN,
624 verb: "link-bundle",
625 target: json!({
626 "endpoint_id": endpoint_id.to_string(),
627 "bundle_id": bundle_id.as_str(),
628 }),
629 idempotency_key: Some(idempotency_key.as_str().to_string()),
630 };
631 audit_and_record(store, ctx, |committed| {
632 let ep = store
633 .link_messaging_bundle(&env_id, endpoint_id, bundle_id, updated_by, idempotency_key)
634 .inspect_err(|err| {
635 if err.is_committed_after_save() {
636 committed.mark_committed();
637 }
638 })
639 .map_err(map_store_err_preserving_noun)?;
640 committed.mark_committed();
641 let summary = EndpointSummary::from(&env_id, &ep);
642 Ok((
643 OpOutcome::new(
644 NOUN,
645 "link-bundle",
646 serde_json::to_value(summary).expect("EndpointSummary is json-safe"),
647 ),
648 super::AuditGens::NONE,
649 ))
650 })
651}
652
653pub fn unlink_bundle(
654 store: &LocalFsStore,
655 flags: &OpFlags,
656 payload: Option<EndpointLinkBundlePayload>,
657) -> Result<OpOutcome, OpError> {
658 if flags.schema_only {
659 return Ok(OpOutcome::new(NOUN, "unlink-bundle", link_bundle_schema()));
660 }
661 let payload = resolve_payload(flags, payload)?;
662 let env_id = parse_env_id(&payload.environment_id)?;
663 let endpoint_id = parse_endpoint_id(&payload.endpoint_id)?;
664 let bundle_id = parse_bundle_id(&payload.bundle_id)?;
665 let updated_by = require_nonempty("updated_by", &payload.updated_by)?;
666 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
667 let ctx = AuditCtx {
668 env_id: env_id.clone(),
669 noun: NOUN,
670 verb: "unlink-bundle",
671 target: json!({
672 "endpoint_id": endpoint_id.to_string(),
673 "bundle_id": bundle_id.as_str(),
674 }),
675 idempotency_key: Some(idempotency_key.as_str().to_string()),
676 };
677 audit_and_record(store, ctx, |committed| {
678 let ep = store
679 .unlink_messaging_bundle(&env_id, endpoint_id, bundle_id, updated_by, idempotency_key)
680 .inspect_err(|err| {
681 if err.is_committed_after_save() {
682 committed.mark_committed();
683 }
684 })
685 .map_err(map_store_err_preserving_noun)?;
686 committed.mark_committed();
687 let summary = EndpointSummary::from(&env_id, &ep);
688 Ok((
689 OpOutcome::new(
690 NOUN,
691 "unlink-bundle",
692 serde_json::to_value(summary).expect("EndpointSummary is json-safe"),
693 ),
694 super::AuditGens::NONE,
695 ))
696 })
697}
698
699pub fn set_welcome_flow(
700 store: &LocalFsStore,
701 flags: &OpFlags,
702 payload: Option<EndpointSetWelcomeFlowPayload>,
703) -> Result<OpOutcome, OpError> {
704 if flags.schema_only {
705 return Ok(OpOutcome::new(
706 NOUN,
707 "set-welcome-flow",
708 set_welcome_flow_schema(),
709 ));
710 }
711 let payload = resolve_payload(flags, payload)?;
712 let env_id = parse_env_id(&payload.environment_id)?;
713 let endpoint_id = parse_endpoint_id(&payload.endpoint_id)?;
714 let bundle_id = parse_bundle_id(&payload.bundle_id)?;
715 let pack_id = require_nonempty("pack_id", &payload.pack_id)?;
716 let flow_id = require_nonempty("flow_id", &payload.flow_id)?;
717 let updated_by = require_nonempty("updated_by", &payload.updated_by)?;
718 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
719 let ctx = AuditCtx {
720 env_id: env_id.clone(),
721 noun: NOUN,
722 verb: "set-welcome-flow",
723 target: json!({
724 "endpoint_id": endpoint_id.to_string(),
725 "bundle_id": bundle_id.as_str(),
726 "pack_id": pack_id,
727 "flow_id": flow_id,
728 }),
729 idempotency_key: Some(idempotency_key.as_str().to_string()),
730 };
731 audit_and_record(store, ctx, |committed| {
732 let ep = store
733 .set_messaging_welcome_flow(
734 &env_id,
735 SetMessagingWelcomeFlowPayload {
736 endpoint_id,
737 bundle_id,
738 pack_id: PackId::new(pack_id),
739 flow_id,
740 updated_by,
741 },
742 idempotency_key,
743 )
744 .inspect_err(|err| {
745 if err.is_committed_after_save() {
746 committed.mark_committed();
747 }
748 })
749 .map_err(map_store_err_preserving_noun)?;
750 committed.mark_committed();
751 let summary = EndpointSummary::from(&env_id, &ep);
752 Ok((
753 OpOutcome::new(
754 NOUN,
755 "set-welcome-flow",
756 serde_json::to_value(summary).expect("EndpointSummary is json-safe"),
757 ),
758 super::AuditGens::NONE,
759 ))
760 })
761}
762
763pub fn remove(
764 store: &LocalFsStore,
765 flags: &OpFlags,
766 payload: Option<EndpointRemovePayload>,
767) -> Result<OpOutcome, OpError> {
768 if flags.schema_only {
769 return Ok(OpOutcome::new(NOUN, "remove", remove_schema()));
770 }
771 let payload = resolve_payload(flags, payload)?;
772 let env_id = parse_env_id(&payload.environment_id)?;
773 let endpoint_id = parse_endpoint_id(&payload.endpoint_id)?;
774 require_nonempty("updated_by", &payload.updated_by)?;
775 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
776 let ctx = AuditCtx {
777 env_id: env_id.clone(),
778 noun: NOUN,
779 verb: "remove",
780 target: json!({"endpoint_id": endpoint_id.to_string()}),
781 idempotency_key: Some(idempotency_key.as_str().to_string()),
782 };
783 audit_and_record(store, ctx, |committed| {
784 let removed_id = store
785 .remove_messaging_endpoint(&env_id, endpoint_id)
786 .inspect_err(|err| {
787 if err.is_committed_after_save() {
788 committed.mark_committed();
789 }
790 })
791 .map_err(map_store_err_preserving_noun)?;
792 committed.mark_committed();
793 Ok((
794 OpOutcome::new(
795 NOUN,
796 "remove",
797 json!({"environment_id": env_id.as_str(), "endpoint_id": removed_id.to_string()}),
798 ),
799 super::AuditGens::NONE,
800 ))
801 })
802}
803
804pub fn rotate_webhook_secret(
805 store: &LocalFsStore,
806 flags: &OpFlags,
807 payload: Option<EndpointRotateWebhookSecretPayload>,
808) -> Result<OpOutcome, OpError> {
809 if flags.schema_only {
810 return Ok(OpOutcome::new(
811 NOUN,
812 "rotate-webhook-secret",
813 rotate_webhook_secret_schema(),
814 ));
815 }
816 let payload = resolve_payload(flags, payload)?;
817 if payload.webhook_secret_ref.is_some() {
821 return Err(OpError::InvalidArgument(
822 "webhook_secret_ref is only honored by a remote `--store-url` store; the local \
823 store mints its own webhook secret value and ref"
824 .to_string(),
825 ));
826 }
827 let env_id = parse_env_id(&payload.environment_id)?;
828 let endpoint_id = parse_endpoint_id(&payload.endpoint_id)?;
829 let updated_by = require_nonempty("updated_by", &payload.updated_by)?;
830 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
831 let ctx = AuditCtx {
832 env_id: env_id.clone(),
833 noun: NOUN,
834 verb: "rotate-webhook-secret",
835 target: json!({"endpoint_id": endpoint_id.to_string()}),
836 idempotency_key: Some(idempotency_key.as_str().to_string()),
837 };
838 audit_and_record(store, ctx, |committed| {
839 let ep = store
840 .rotate_messaging_webhook_secret(&env_id, endpoint_id, updated_by, idempotency_key)
841 .inspect_err(|err| {
842 if err.is_committed_after_save() {
843 committed.mark_committed();
844 }
845 })
846 .map_err(map_store_err_preserving_noun)?;
847 committed.mark_committed();
848 let summary = EndpointSummary::from(&env_id, &ep);
849 Ok((
850 OpOutcome::new(
851 NOUN,
852 "rotate-webhook-secret",
853 serde_json::to_value(summary).expect("EndpointSummary is json-safe"),
854 ),
855 super::AuditGens::NONE,
856 ))
857 })
858}
859
860fn resolve_payload<T: serde::de::DeserializeOwned>(
863 flags: &OpFlags,
864 payload: Option<T>,
865) -> Result<T, OpError> {
866 if let Some(p) = payload {
867 return Ok(p);
868 }
869 if let Some(path) = &flags.answers {
870 return super::load_answers::<T>(path);
871 }
872 Err(OpError::InvalidArgument(
873 "no payload provided: pass --answers <path> or supply positional args".to_string(),
874 ))
875}
876
877fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
878 EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
879}
880
881fn parse_endpoint_id(raw: &str) -> Result<MessagingEndpointId, OpError> {
882 ulid::Ulid::from_str(raw)
883 .map(MessagingEndpointId)
884 .map_err(|e| OpError::InvalidArgument(format!("endpoint_id: {e}")))
885}
886
887fn parse_bundle_id(raw: &str) -> Result<BundleId, OpError> {
888 if raw.trim().is_empty() {
889 return Err(OpError::InvalidArgument(
890 "bundle_id must not be empty".to_string(),
891 ));
892 }
893 Ok(BundleId::new(raw))
894}
895
896fn require_nonempty(field: &str, value: &str) -> Result<String, OpError> {
897 if value.trim().is_empty() {
898 return Err(OpError::InvalidArgument(format!(
899 "{field} must not be empty"
900 )));
901 }
902 Ok(value.to_string())
903}
904
905fn webhook_secret_policy() -> greentic_secrets_lib::GeneratedSecretRequirement {
917 greentic_secrets_lib::GeneratedSecretRequirement {
918 policy: "random".to_string(),
919 length: 32,
920 encoding: "raw_text".to_string(),
921 scope: greentic_secrets_lib::GeneratedSecretScope {
922 level: "tenant".to_string(),
923 team: Some("_".to_string()),
924 },
925 regenerate_if_present: false,
926 }
927}
928
929fn generate_webhook_secret() -> Result<String, OpError> {
931 let (bytes, _) = greentic_secrets_lib::generate_secret_value(&webhook_secret_policy())
932 .map_err(|e| OpError::InvalidArgument(format!("webhook secret generation: {e}")))?;
933 String::from_utf8(bytes)
934 .map_err(|e| OpError::InvalidArgument(format!("webhook secret encoding: {e}")))
935}
936
937fn build_webhook_secret_ref(
952 env_id: &EnvId,
953 endpoint_id: &MessagingEndpointId,
954 tenant: &str,
955) -> Result<SecretRef, OpError> {
956 let eid_lower = endpoint_id.to_string().to_lowercase();
957 let uri = format!(
958 "secret://{}/{}/_/messaging-{}/webhook_secret",
959 env_id.as_str(),
960 tenant,
961 eid_lower
962 );
963 SecretRef::try_new(uri)
964 .map_err(|e| OpError::InvalidArgument(format!("webhook secret ref: {e}")))
965}
966
967pub(crate) fn provision_webhook_secret(
988 store: &LocalFsStore,
989 env_id: &EnvId,
990 endpoint_id: &MessagingEndpointId,
991 owner: Option<&str>,
992 custodial: bool,
993 existing_ref: Option<&SecretRef>,
994) -> Result<SecretRef, OpError> {
995 let tenant = match owner {
1000 Some(owner) => owner,
1001 None if custodial => "default",
1002 None => {
1003 return Err(OpError::Conflict(format!(
1004 "a webhook secret on a non-dev-store secrets backend requires the environment to \
1005 declare an owning tenant (`tenant_org_id`); env `{}` has none",
1006 env_id.as_str()
1007 )));
1008 }
1009 };
1010 let secret_ref = match existing_ref {
1011 Some(r) => r.clone(),
1012 None => build_webhook_secret_ref(env_id, endpoint_id, tenant)?,
1013 };
1014 if custodial {
1015 let value = generate_webhook_secret()?;
1016 write_webhook_secret_to_devstore(store, env_id, &secret_ref, &value)?;
1017 }
1018 Ok(secret_ref)
1019}
1020
1021fn write_webhook_secret_to_devstore(
1025 store: &LocalFsStore,
1026 env_id: &EnvId,
1027 secret_ref: &SecretRef,
1028 value: &str,
1029) -> Result<(), OpError> {
1030 let env_dir = store.env_dir(env_id)?;
1031 let dev_path = super::secrets::resolve_dev_store_path(
1032 &env_dir,
1033 std::env::var_os(super::secrets::DEV_SECRETS_PATH_ENV).map(PathBuf::from),
1034 );
1035 let store_uri = super::secrets::secret_ref_to_store_uri(secret_ref)?;
1036 super::secrets::dev_store_put(&dev_path, &store_uri, value)
1037}
1038
1039fn verb_schema(verb: &str, fields: &[&str]) -> Value {
1048 json!({ "noun": NOUN, "verb": verb, "fields": fields })
1049}
1050
1051fn add_schema() -> Value {
1052 verb_schema(
1053 "add",
1054 &[
1055 "environment_id",
1056 "provider_id",
1057 "provider_type",
1058 "display_name",
1059 "secret_refs (array of secret:// URIs)",
1060 "webhook_secret_ref (optional; telegram-class only; required on a remote --store-url store)",
1061 "idempotency_key",
1062 "updated_by",
1063 ],
1064 )
1065}
1066
1067fn link_bundle_schema() -> Value {
1068 verb_schema(
1069 "link-bundle / unlink-bundle",
1070 &[
1071 "environment_id",
1072 "endpoint_id",
1073 "bundle_id",
1074 "idempotency_key",
1075 "updated_by",
1076 ],
1077 )
1078}
1079
1080fn set_welcome_flow_schema() -> Value {
1081 verb_schema(
1082 "set-welcome-flow",
1083 &[
1084 "environment_id",
1085 "endpoint_id",
1086 "bundle_id",
1087 "pack_id",
1088 "flow_id",
1089 "idempotency_key",
1090 "updated_by",
1091 ],
1092 )
1093}
1094
1095fn remove_schema() -> Value {
1096 verb_schema(
1097 "remove",
1098 &[
1099 "environment_id",
1100 "endpoint_id",
1101 "idempotency_key",
1102 "updated_by",
1103 ],
1104 )
1105}
1106
1107fn rotate_webhook_secret_schema() -> Value {
1108 verb_schema(
1109 "rotate-webhook-secret",
1110 &[
1111 "environment_id",
1112 "endpoint_id",
1113 "idempotency_key",
1114 "updated_by",
1115 ],
1116 )
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 use super::*;
1122 use crate::cli::tests_common::{make_bundle_deployment, make_env};
1123 use crate::environment::EnvironmentStore;
1124 use crate::environment::messaging::MessagingEndpointIndexEntry;
1125 use tempfile::tempdir;
1126
1127 fn seeded_store_with_bundles(
1128 bundle_ids: &[&str],
1129 ) -> (tempfile::TempDir, LocalFsStore, Vec<BundleId>) {
1130 let dir = tempdir().unwrap();
1131 let store = LocalFsStore::new(dir.path());
1132 let mut env = make_env("local");
1133 let mut ids = Vec::new();
1134 for id in bundle_ids {
1135 let b = make_bundle_deployment("local", id);
1136 ids.push(b.bundle_id.clone());
1137 env.bundles.push(b);
1138 }
1139 store.save(&env).unwrap();
1140 (dir, store, ids)
1141 }
1142
1143 fn add_payload(provider_type: &str, provider_id: &str, key: &str) -> EndpointAddPayload {
1144 EndpointAddPayload {
1145 environment_id: "local".to_string(),
1146 provider_id: provider_id.to_string(),
1147 provider_type: provider_type.to_string(),
1148 display_name: format!("{provider_type} {provider_id}"),
1149 secret_refs: vec![],
1150 webhook_secret_ref: None,
1151 idempotency_key: Some(key.to_string()),
1152 updated_by: "tester".to_string(),
1153 }
1154 }
1155
1156 fn endpoint_id_from(outcome: &OpOutcome) -> MessagingEndpointId {
1157 let raw = outcome.result["endpoint_id"].as_str().expect("endpoint_id");
1158 parse_endpoint_id(raw).expect("endpoint_id parses")
1159 }
1160
1161 #[test]
1162 fn add_then_show_returns_endpoint() {
1163 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1164 let added = add(
1165 &store,
1166 &OpFlags::default(),
1167 Some(add_payload("teams", "legal-bot", "k1")),
1168 )
1169 .unwrap();
1170 let id = endpoint_id_from(&added);
1171 let shown = show(&store, &OpFlags::default(), "local", &id.to_string()).unwrap();
1172 assert_eq!(shown.result["provider_type"], "teams");
1173 assert_eq!(shown.result["provider_id"], "legal-bot");
1174 }
1175
1176 #[test]
1177 fn list_enumerates_all_endpoints() {
1178 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1179 add(
1180 &store,
1181 &OpFlags::default(),
1182 Some(add_payload("teams", "legal", "k1")),
1183 )
1184 .unwrap();
1185 add(
1186 &store,
1187 &OpFlags::default(),
1188 Some(add_payload("teams", "accounting", "k2")),
1189 )
1190 .unwrap();
1191 let listed = list(&store, &OpFlags::default(), "local").unwrap();
1192 let endpoints = listed.result["endpoints"].as_array().unwrap();
1193 assert_eq!(endpoints.len(), 2);
1194 }
1195
1196 #[test]
1197 fn duplicate_provider_instance_rejected_at_verb_layer() {
1198 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1199 add(
1200 &store,
1201 &OpFlags::default(),
1202 Some(add_payload("teams", "legal", "k1")),
1203 )
1204 .unwrap();
1205 let err = add(
1206 &store,
1207 &OpFlags::default(),
1208 Some(add_payload("teams", "legal", "k2")),
1209 )
1210 .unwrap_err();
1211 assert!(matches!(err, OpError::Conflict(_)));
1212 }
1213
1214 #[test]
1215 fn idempotent_add_returns_same_endpoint() {
1216 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1217 let first = add(
1218 &store,
1219 &OpFlags::default(),
1220 Some(add_payload("teams", "legal", "k-replay")),
1221 )
1222 .unwrap();
1223 let second = add(
1224 &store,
1225 &OpFlags::default(),
1226 Some(add_payload("teams", "legal", "k-replay")),
1227 )
1228 .unwrap();
1229 assert_eq!(
1230 endpoint_id_from(&first),
1231 endpoint_id_from(&second),
1232 "idempotent replay must return the original endpoint"
1233 );
1234 }
1235
1236 #[test]
1237 fn same_key_with_different_payload_is_conflict() {
1238 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1239 add(
1240 &store,
1241 &OpFlags::default(),
1242 Some(add_payload("teams", "legal", "k-shared")),
1243 )
1244 .unwrap();
1245 let err = add(
1246 &store,
1247 &OpFlags::default(),
1248 Some(add_payload("slack", "ops", "k-shared")),
1249 )
1250 .unwrap_err();
1251 assert!(matches!(err, OpError::Conflict(_)));
1252 }
1253
1254 #[test]
1255 fn link_then_unlink_bundle_updates_endpoint() {
1256 let (_dir, store, bundle_ids) = seeded_store_with_bundles(&["legal-pack"]);
1257 let bundle = &bundle_ids[0];
1258 let added = add(
1259 &store,
1260 &OpFlags::default(),
1261 Some(add_payload("teams", "legal", "k1")),
1262 )
1263 .unwrap();
1264 let id = endpoint_id_from(&added);
1265 let linked = link_bundle(
1266 &store,
1267 &OpFlags::default(),
1268 Some(EndpointLinkBundlePayload {
1269 environment_id: "local".to_string(),
1270 endpoint_id: id.to_string(),
1271 bundle_id: bundle.as_str().to_string(),
1272 idempotency_key: Some("k2".to_string()),
1273 updated_by: "tester".to_string(),
1274 }),
1275 )
1276 .unwrap();
1277 let linked_bundles = linked.result["linked_bundles"].as_array().unwrap();
1278 assert_eq!(linked_bundles.len(), 1);
1279 let unlinked = unlink_bundle(
1280 &store,
1281 &OpFlags::default(),
1282 Some(EndpointLinkBundlePayload {
1283 environment_id: "local".to_string(),
1284 endpoint_id: id.to_string(),
1285 bundle_id: bundle.as_str().to_string(),
1286 idempotency_key: Some("k3".to_string()),
1287 updated_by: "tester".to_string(),
1288 }),
1289 )
1290 .unwrap();
1291 assert!(
1292 unlinked.result["linked_bundles"]
1293 .as_array()
1294 .unwrap()
1295 .is_empty()
1296 );
1297 }
1298
1299 #[test]
1300 fn link_unknown_bundle_is_not_found() {
1301 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1302 let added = add(
1303 &store,
1304 &OpFlags::default(),
1305 Some(add_payload("teams", "legal", "k1")),
1306 )
1307 .unwrap();
1308 let id = endpoint_id_from(&added);
1309 let err = link_bundle(
1310 &store,
1311 &OpFlags::default(),
1312 Some(EndpointLinkBundlePayload {
1313 environment_id: "local".to_string(),
1314 endpoint_id: id.to_string(),
1315 bundle_id: "ghost-bundle".to_string(),
1316 idempotency_key: Some("k2".to_string()),
1317 updated_by: "tester".to_string(),
1318 }),
1319 )
1320 .unwrap_err();
1321 assert!(matches!(err, OpError::NotFound(_)));
1322 }
1323
1324 #[test]
1325 fn set_welcome_flow_requires_linked_bundle() {
1326 let (_dir, store, bundle_ids) = seeded_store_with_bundles(&["legal-pack"]);
1327 let bundle = &bundle_ids[0];
1328 let added = add(
1329 &store,
1330 &OpFlags::default(),
1331 Some(add_payload("teams", "legal", "k1")),
1332 )
1333 .unwrap();
1334 let id = endpoint_id_from(&added);
1335 let err = set_welcome_flow(
1337 &store,
1338 &OpFlags::default(),
1339 Some(EndpointSetWelcomeFlowPayload {
1340 environment_id: "local".to_string(),
1341 endpoint_id: id.to_string(),
1342 bundle_id: bundle.as_str().to_string(),
1343 pack_id: "legal".to_string(),
1344 flow_id: "main".to_string(),
1345 idempotency_key: Some("k2".to_string()),
1346 updated_by: "tester".to_string(),
1347 }),
1348 )
1349 .unwrap_err();
1350 assert!(matches!(err, OpError::InvalidArgument(_)));
1351 link_bundle(
1353 &store,
1354 &OpFlags::default(),
1355 Some(EndpointLinkBundlePayload {
1356 environment_id: "local".to_string(),
1357 endpoint_id: id.to_string(),
1358 bundle_id: bundle.as_str().to_string(),
1359 idempotency_key: Some("k3".to_string()),
1360 updated_by: "tester".to_string(),
1361 }),
1362 )
1363 .unwrap();
1364 let set = set_welcome_flow(
1365 &store,
1366 &OpFlags::default(),
1367 Some(EndpointSetWelcomeFlowPayload {
1368 environment_id: "local".to_string(),
1369 endpoint_id: id.to_string(),
1370 bundle_id: bundle.as_str().to_string(),
1371 pack_id: "legal".to_string(),
1372 flow_id: "main".to_string(),
1373 idempotency_key: Some("k4".to_string()),
1374 updated_by: "tester".to_string(),
1375 }),
1376 )
1377 .unwrap();
1378 assert_eq!(set.result["welcome_flow"]["flow_id"], "main");
1379 }
1380
1381 #[test]
1382 fn unlink_welcome_flow_bundle_is_rejected() {
1383 let (_dir, store, bundle_ids) = seeded_store_with_bundles(&["legal-pack"]);
1384 let bundle = &bundle_ids[0];
1385 let added = add(
1386 &store,
1387 &OpFlags::default(),
1388 Some(add_payload("teams", "legal", "k1")),
1389 )
1390 .unwrap();
1391 let id = endpoint_id_from(&added);
1392 link_bundle(
1393 &store,
1394 &OpFlags::default(),
1395 Some(EndpointLinkBundlePayload {
1396 environment_id: "local".to_string(),
1397 endpoint_id: id.to_string(),
1398 bundle_id: bundle.as_str().to_string(),
1399 idempotency_key: Some("k2".to_string()),
1400 updated_by: "tester".to_string(),
1401 }),
1402 )
1403 .unwrap();
1404 set_welcome_flow(
1405 &store,
1406 &OpFlags::default(),
1407 Some(EndpointSetWelcomeFlowPayload {
1408 environment_id: "local".to_string(),
1409 endpoint_id: id.to_string(),
1410 bundle_id: bundle.as_str().to_string(),
1411 pack_id: "legal".to_string(),
1412 flow_id: "main".to_string(),
1413 idempotency_key: Some("k3".to_string()),
1414 updated_by: "tester".to_string(),
1415 }),
1416 )
1417 .unwrap();
1418 let err = unlink_bundle(
1419 &store,
1420 &OpFlags::default(),
1421 Some(EndpointLinkBundlePayload {
1422 environment_id: "local".to_string(),
1423 endpoint_id: id.to_string(),
1424 bundle_id: bundle.as_str().to_string(),
1425 idempotency_key: Some("k4".to_string()),
1426 updated_by: "tester".to_string(),
1427 }),
1428 )
1429 .unwrap_err();
1430 assert!(matches!(err, OpError::Conflict(_)));
1431 }
1432
1433 #[test]
1434 fn remove_then_show_returns_not_found() {
1435 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1436 let added = add(
1437 &store,
1438 &OpFlags::default(),
1439 Some(add_payload("teams", "legal", "k1")),
1440 )
1441 .unwrap();
1442 let id = endpoint_id_from(&added);
1443 remove(
1444 &store,
1445 &OpFlags::default(),
1446 Some(EndpointRemovePayload {
1447 environment_id: "local".to_string(),
1448 endpoint_id: id.to_string(),
1449 idempotency_key: Some("k2".to_string()),
1450 updated_by: "tester".to_string(),
1451 }),
1452 )
1453 .unwrap();
1454 let err = show(&store, &OpFlags::default(), "local", &id.to_string()).unwrap_err();
1455 assert!(matches!(err, OpError::NotFound(_)));
1456 }
1457
1458 #[test]
1459 fn idempotent_remove_succeeds_when_absent() {
1460 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1461 let outcome = remove(
1462 &store,
1463 &OpFlags::default(),
1464 Some(EndpointRemovePayload {
1465 environment_id: "local".to_string(),
1466 endpoint_id: MessagingEndpointId::new().to_string(),
1467 idempotency_key: Some("k1".to_string()),
1468 updated_by: "tester".to_string(),
1469 }),
1470 )
1471 .unwrap();
1472 assert_eq!(outcome.op, "remove");
1473 }
1474
1475 #[test]
1476 fn add_materializes_index_and_per_endpoint_file() {
1477 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1478 let added = add(
1479 &store,
1480 &OpFlags::default(),
1481 Some(add_payload("teams", "legal", "k1")),
1482 )
1483 .unwrap();
1484 let id = endpoint_id_from(&added);
1485 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1486 let endpoint_path = env_dir.join("messaging").join(format!("{id}.json"));
1487 let index_path = env_dir.join("messaging").join("index.json");
1488 assert!(
1489 endpoint_path.exists(),
1490 "per-endpoint file must be materialized"
1491 );
1492 assert!(index_path.exists(), "index.json must be materialized");
1493 let index: Vec<MessagingEndpointIndexEntry> =
1494 serde_json::from_slice(&std::fs::read(&index_path).unwrap()).unwrap();
1495 assert_eq!(index.len(), 1);
1496 assert_eq!(index[0].endpoint_id, id);
1497 }
1498
1499 #[test]
1500 fn remove_prunes_per_endpoint_file_and_deletes_empty_index() {
1501 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1502 let added = add(
1503 &store,
1504 &OpFlags::default(),
1505 Some(add_payload("teams", "legal", "k1")),
1506 )
1507 .unwrap();
1508 let id = endpoint_id_from(&added);
1509 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1510 let endpoint_path = env_dir.join("messaging").join(format!("{id}.json"));
1511 let index_path = env_dir.join("messaging").join("index.json");
1512 assert!(endpoint_path.exists());
1513 remove(
1514 &store,
1515 &OpFlags::default(),
1516 Some(EndpointRemovePayload {
1517 environment_id: "local".to_string(),
1518 endpoint_id: id.to_string(),
1519 idempotency_key: Some("k2".to_string()),
1520 updated_by: "tester".to_string(),
1521 }),
1522 )
1523 .unwrap();
1524 assert!(
1525 !endpoint_path.exists(),
1526 "per-endpoint file must be pruned on remove"
1527 );
1528 assert!(
1529 !index_path.exists(),
1530 "index.json must be deleted when env has no endpoints"
1531 );
1532 }
1533
1534 #[test]
1535 fn idempotent_replay_repairs_stale_projection() {
1536 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1541 let added = add(
1542 &store,
1543 &OpFlags::default(),
1544 Some(add_payload("teams", "legal", "k1")),
1545 )
1546 .unwrap();
1547 let id = endpoint_id_from(&added);
1548 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1549 let endpoint_path = env_dir.join("messaging").join(format!("{id}.json"));
1550 let index_path = env_dir.join("messaging").join("index.json");
1551 std::fs::remove_file(&endpoint_path).unwrap();
1554 std::fs::remove_file(&index_path).unwrap();
1555 add(
1558 &store,
1559 &OpFlags::default(),
1560 Some(add_payload("teams", "legal", "k1")),
1561 )
1562 .unwrap();
1563 assert!(
1564 endpoint_path.exists(),
1565 "idempotent add replay must republish the per-endpoint projection"
1566 );
1567 assert!(
1568 index_path.exists(),
1569 "idempotent add replay must republish the index"
1570 );
1571 }
1572
1573 #[test]
1574 fn idempotent_remove_replay_repairs_stale_projection() {
1575 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1579 let added = add(
1580 &store,
1581 &OpFlags::default(),
1582 Some(add_payload("teams", "legal", "k1")),
1583 )
1584 .unwrap();
1585 let id = endpoint_id_from(&added);
1586 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1587 let endpoint_path = env_dir.join("messaging").join(format!("{id}.json"));
1588 let env_path = env_dir.join("environment.json");
1592 let mut env: greentic_deploy_spec::Environment =
1593 serde_json::from_slice(&std::fs::read(&env_path).unwrap()).unwrap();
1594 env.messaging_endpoints.clear();
1595 std::fs::write(&env_path, serde_json::to_vec_pretty(&env).unwrap()).unwrap();
1596 assert!(
1597 endpoint_path.exists(),
1598 "fixture: per-endpoint file must still be present pre-replay"
1599 );
1600 remove(
1603 &store,
1604 &OpFlags::default(),
1605 Some(EndpointRemovePayload {
1606 environment_id: "local".to_string(),
1607 endpoint_id: id.to_string(),
1608 idempotency_key: Some("k2".to_string()),
1609 updated_by: "tester".to_string(),
1610 }),
1611 )
1612 .unwrap();
1613 assert!(
1614 !endpoint_path.exists(),
1615 "idempotent remove replay must prune the stale per-endpoint file"
1616 );
1617 }
1618
1619 #[test]
1620 fn set_welcome_flow_rejects_unknown_pack_id_when_revisions_exist() {
1621 use greentic_deploy_spec::RevisionLifecycle;
1626 let (_dir, store, bundle_ids) = seeded_store_with_bundles(&["legal-pack"]);
1627 let bundle = &bundle_ids[0];
1628 let env_id_typed = EnvId::try_from("local").unwrap();
1631 let mut env = store.load(&env_id_typed).unwrap();
1632 let deployment_id = env.bundles[0].deployment_id;
1633 let rev = crate::cli::tests_common::make_revision(
1634 "local",
1635 bundle.as_str(),
1636 &deployment_id,
1637 1,
1638 RevisionLifecycle::Ready,
1639 );
1640 env.bundles[0].current_revisions.push(rev.revision_id);
1641 env.revisions.push(rev);
1642 store.save(&env).unwrap();
1643 let added = add(
1645 &store,
1646 &OpFlags::default(),
1647 Some(add_payload("teams", "legal", "k1")),
1648 )
1649 .unwrap();
1650 let id = endpoint_id_from(&added);
1651 link_bundle(
1652 &store,
1653 &OpFlags::default(),
1654 Some(EndpointLinkBundlePayload {
1655 environment_id: "local".to_string(),
1656 endpoint_id: id.to_string(),
1657 bundle_id: bundle.as_str().to_string(),
1658 idempotency_key: Some("k2".to_string()),
1659 updated_by: "tester".to_string(),
1660 }),
1661 )
1662 .unwrap();
1663 let err = set_welcome_flow(
1665 &store,
1666 &OpFlags::default(),
1667 Some(EndpointSetWelcomeFlowPayload {
1668 environment_id: "local".to_string(),
1669 endpoint_id: id.to_string(),
1670 bundle_id: bundle.as_str().to_string(),
1671 pack_id: "typo-pack".to_string(),
1672 flow_id: "main".to_string(),
1673 idempotency_key: Some("k3".to_string()),
1674 updated_by: "tester".to_string(),
1675 }),
1676 )
1677 .unwrap_err();
1678 assert!(
1679 matches!(err, OpError::InvalidArgument(_)),
1680 "typo'd pack_id must be rejected when bundle has current revisions to compare against, got: {err:?}"
1681 );
1682 let ok = set_welcome_flow(
1684 &store,
1685 &OpFlags::default(),
1686 Some(EndpointSetWelcomeFlowPayload {
1687 environment_id: "local".to_string(),
1688 endpoint_id: id.to_string(),
1689 bundle_id: bundle.as_str().to_string(),
1690 pack_id: "greentic.test.pack".to_string(),
1691 flow_id: "main".to_string(),
1692 idempotency_key: Some("k4".to_string()),
1693 updated_by: "tester".to_string(),
1694 }),
1695 );
1696 assert!(
1697 ok.is_ok(),
1698 "pack_id matching the bundle's pack_list must be accepted"
1699 );
1700 }
1701
1702 #[test]
1703 fn set_welcome_flow_accepts_any_pack_id_when_bundle_has_no_revisions_yet() {
1704 let (_dir, store, bundle_ids) = seeded_store_with_bundles(&["legal-pack"]);
1709 let bundle = &bundle_ids[0];
1710 let added = add(
1711 &store,
1712 &OpFlags::default(),
1713 Some(add_payload("teams", "legal", "k1")),
1714 )
1715 .unwrap();
1716 let id = endpoint_id_from(&added);
1717 link_bundle(
1718 &store,
1719 &OpFlags::default(),
1720 Some(EndpointLinkBundlePayload {
1721 environment_id: "local".to_string(),
1722 endpoint_id: id.to_string(),
1723 bundle_id: bundle.as_str().to_string(),
1724 idempotency_key: Some("k2".to_string()),
1725 updated_by: "tester".to_string(),
1726 }),
1727 )
1728 .unwrap();
1729 let outcome = set_welcome_flow(
1730 &store,
1731 &OpFlags::default(),
1732 Some(EndpointSetWelcomeFlowPayload {
1733 environment_id: "local".to_string(),
1734 endpoint_id: id.to_string(),
1735 bundle_id: bundle.as_str().to_string(),
1736 pack_id: "future-pack".to_string(),
1737 flow_id: "main".to_string(),
1738 idempotency_key: Some("k3".to_string()),
1739 updated_by: "tester".to_string(),
1740 }),
1741 )
1742 .unwrap();
1743 assert_eq!(outcome.result["welcome_flow"]["pack_id"], "future-pack");
1744 }
1745
1746 #[test]
1747 fn add_with_missing_required_field_is_invalid_argument() {
1748 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1749 let bad = EndpointAddPayload {
1750 environment_id: "local".to_string(),
1751 provider_id: "".to_string(),
1752 provider_type: "teams".to_string(),
1753 display_name: "x".to_string(),
1754 secret_refs: vec![],
1755 webhook_secret_ref: None,
1756 idempotency_key: Some("k1".to_string()),
1757 updated_by: "tester".to_string(),
1758 };
1759 let err = add(&store, &OpFlags::default(), Some(bad)).unwrap_err();
1760 assert!(matches!(err, OpError::InvalidArgument(_)));
1761 }
1762
1763 fn load_raw_endpoint(
1767 store: &LocalFsStore,
1768 endpoint_id: &MessagingEndpointId,
1769 ) -> MessagingEndpoint {
1770 let env_id = EnvId::try_from("local").unwrap();
1771 let env = store.load(&env_id).unwrap();
1772 env.messaging_endpoints
1773 .into_iter()
1774 .find(|e| e.endpoint_id == *endpoint_id)
1775 .expect("endpoint must exist")
1776 }
1777
1778 fn read_devstore_value(store: &LocalFsStore, secret_ref: &SecretRef) -> String {
1783 use greentic_secrets_lib::{DevStore, SecretsStore};
1784 let env_id = EnvId::try_from("local").unwrap();
1785 let env_dir = store.env_dir(&env_id).unwrap();
1786 let dev_path = crate::cli::secrets::resolve_dev_store_path(
1787 &env_dir,
1788 std::env::var_os(crate::cli::secrets::DEV_SECRETS_PATH_ENV).map(PathBuf::from),
1789 );
1790 let store_uri =
1791 crate::cli::secrets::secret_ref_to_store_uri(secret_ref).expect("store-aligned ref");
1792 let dev = DevStore::with_path(dev_path).expect("open dev store");
1793 let bytes = tokio::runtime::Builder::new_current_thread()
1794 .enable_all()
1795 .build()
1796 .unwrap()
1797 .block_on(async { dev.get(&store_uri).await.unwrap() });
1798 bytes.into_iter().map(|b| b as char).collect()
1799 }
1800
1801 #[test]
1802 fn add_telegram_generates_webhook_secret_ref() {
1803 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1804 let outcome = add(
1805 &store,
1806 &OpFlags::default(),
1807 Some(add_payload("telegram", "legal-bot", "k1")),
1808 )
1809 .unwrap();
1810 let id = endpoint_id_from(&outcome);
1811 let ep = load_raw_endpoint(&store, &id);
1812 let secret_ref = ep
1813 .webhook_secret_ref
1814 .as_ref()
1815 .expect("telegram must have webhook_secret_ref");
1816 let eid_lower = id.to_string().to_lowercase();
1817 let expected_uri = format!("secret://local/default/_/messaging-{eid_lower}/webhook_secret");
1818 assert_eq!(secret_ref.as_str(), expected_uri);
1819 let value = read_devstore_value(&store, secret_ref);
1821 assert!(
1822 value.len() >= 32,
1823 "dev-store secret must be ≥32 chars, got {}",
1824 value.len()
1825 );
1826 }
1827
1828 #[test]
1829 fn add_telegram_with_supplied_ref_stamps_it_and_skips_auto_mint() {
1830 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1831 let supplied = "secret://local/default/_/messaging-byo/webhook_secret";
1832 let outcome = add(
1833 &store,
1834 &OpFlags::default(),
1835 Some(EndpointAddPayload {
1836 webhook_secret_ref: Some(supplied.to_string()),
1837 ..add_payload("telegram", "legal-bot", "k1")
1838 }),
1839 )
1840 .unwrap();
1841 let id = endpoint_id_from(&outcome);
1842 let ep = load_raw_endpoint(&store, &id);
1843 assert_eq!(
1846 ep.webhook_secret_ref.as_ref().map(|r| r.as_str()),
1847 Some(supplied)
1848 );
1849 }
1850
1851 #[test]
1852 fn add_telegram_dotted_generates_webhook_secret_ref() {
1853 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1854 let outcome = add(
1855 &store,
1856 &OpFlags::default(),
1857 Some(add_payload("messaging.telegram.bot", "tg-bot", "k1")),
1858 )
1859 .unwrap();
1860 let id = endpoint_id_from(&outcome);
1861 let ep = load_raw_endpoint(&store, &id);
1862 assert!(
1863 ep.webhook_secret_ref.is_some(),
1864 "messaging.telegram.bot must auto-gen webhook_secret_ref"
1865 );
1866 }
1867
1868 #[test]
1869 fn add_non_telegram_has_no_webhook_secret_ref() {
1870 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1871 let outcome = add(
1872 &store,
1873 &OpFlags::default(),
1874 Some(add_payload("slack", "ops", "k1")),
1875 )
1876 .unwrap();
1877 let id = endpoint_id_from(&outcome);
1878 let ep = load_raw_endpoint(&store, &id);
1879 assert!(
1880 ep.webhook_secret_ref.is_none(),
1881 "non-telegram provider must not auto-gen webhook_secret_ref"
1882 );
1883 }
1884
1885 #[test]
1886 fn idempotent_add_preserves_original_webhook_secret_ref() {
1887 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1888 let first = add(
1889 &store,
1890 &OpFlags::default(),
1891 Some(add_payload("telegram", "legal-bot", "k-replay")),
1892 )
1893 .unwrap();
1894 let id = endpoint_id_from(&first);
1895 let ref_1 = load_raw_endpoint(&store, &id)
1896 .webhook_secret_ref
1897 .expect("first call must generate a ref");
1898 let value_1 = read_devstore_value(&store, &ref_1);
1899 add(
1902 &store,
1903 &OpFlags::default(),
1904 Some(add_payload("telegram", "legal-bot", "k-replay")),
1905 )
1906 .unwrap();
1907 let ref_2 = load_raw_endpoint(&store, &id)
1908 .webhook_secret_ref
1909 .expect("replay must preserve the ref");
1910 let value_2 = read_devstore_value(&store, &ref_2);
1911 assert_eq!(
1912 ref_1, ref_2,
1913 "idempotent replay must return the SAME SecretRef"
1914 );
1915 assert_eq!(
1916 value_1, value_2,
1917 "idempotent replay must not overwrite the dev-store value"
1918 );
1919 }
1920
1921 #[test]
1922 fn webhook_secret_ref_passes_deploy_spec_validate() {
1923 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1924 let outcome = add(
1925 &store,
1926 &OpFlags::default(),
1927 Some(add_payload("telegram", "legal-bot", "k1")),
1928 )
1929 .unwrap();
1930 let id = endpoint_id_from(&outcome);
1931 let ep = load_raw_endpoint(&store, &id);
1932 ep.validate()
1933 .expect("endpoint with webhook_secret_ref must pass deploy-spec validate");
1934 }
1935
1936 fn devstore_has_value(store: &LocalFsStore, secret_ref: &SecretRef) -> bool {
1939 use greentic_secrets_lib::{DevStore, SecretsStore};
1940 let env_id = EnvId::try_from("local").unwrap();
1941 let env_dir = store.env_dir(&env_id).unwrap();
1942 let dev_path = crate::cli::secrets::resolve_dev_store_path(
1943 &env_dir,
1944 std::env::var_os(crate::cli::secrets::DEV_SECRETS_PATH_ENV).map(PathBuf::from),
1945 );
1946 let store_uri =
1947 crate::cli::secrets::secret_ref_to_store_uri(secret_ref).expect("store-aligned ref");
1948 let Ok(dev) = DevStore::with_path(dev_path) else {
1951 return false;
1952 };
1953 tokio::runtime::Builder::new_current_thread()
1954 .enable_all()
1955 .build()
1956 .unwrap()
1957 .block_on(async { dev.get(&store_uri).await.is_ok() })
1958 }
1959
1960 #[test]
1961 fn build_webhook_secret_ref_uses_provided_tenant() {
1962 let env_id = EnvId::try_from("vault-demo").unwrap();
1965 let eid = MessagingEndpointId::new();
1966 let eid_lower = eid.to_string().to_lowercase();
1967 let r = build_webhook_secret_ref(&env_id, &eid, "tenant-default").unwrap();
1968 assert_eq!(
1969 r.as_str(),
1970 format!("secret://vault-demo/tenant-default/_/messaging-{eid_lower}/webhook_secret")
1971 );
1972 }
1973
1974 #[test]
1975 fn provision_webhook_secret_honors_tenant_and_custodial_gate() {
1976 let (_dir, store, _) = seeded_store_with_bundles(&[]);
1977 let env_id = EnvId::try_from("local").unwrap();
1978 let eid = MessagingEndpointId::new();
1979 let eid_lower = eid.to_string().to_lowercase();
1980
1981 let ref_vault =
1984 provision_webhook_secret(&store, &env_id, &eid, Some("tenant-default"), false, None)
1985 .unwrap();
1986 assert_eq!(
1987 ref_vault.as_str(),
1988 format!("secret://local/tenant-default/_/messaging-{eid_lower}/webhook_secret")
1989 );
1990 assert!(
1991 !devstore_has_value(&store, &ref_vault),
1992 "non-custodial provisioning must not write a dev-store value"
1993 );
1994
1995 let ref_dev = provision_webhook_secret(&store, &env_id, &eid, None, true, None).unwrap();
1998 assert_eq!(
1999 ref_dev.as_str(),
2000 format!("secret://local/default/_/messaging-{eid_lower}/webhook_secret")
2001 );
2002 assert!(
2003 devstore_has_value(&store, &ref_dev),
2004 "custodial provisioning must write the dev-store value"
2005 );
2006 assert!(
2007 read_devstore_value(&store, &ref_dev).len() >= 32,
2008 "custodial webhook secret must be ≥32 chars"
2009 );
2010 }
2011
2012 #[test]
2013 fn provision_webhook_secret_fails_closed_on_non_custodial_without_owner() {
2014 let (_dir, store, _) = seeded_store_with_bundles(&[]);
2015 let env_id = EnvId::try_from("local").unwrap();
2016 let eid = MessagingEndpointId::new();
2017 let err = provision_webhook_secret(&store, &env_id, &eid, None, false, None).unwrap_err();
2020 let OpError::Conflict(msg) = err else {
2021 panic!("expected OpError::Conflict");
2022 };
2023 assert!(msg.contains("owning tenant"), "got: {msg}");
2024 }
2025
2026 fn rotate_payload(endpoint_id: &str, key: &str) -> EndpointRotateWebhookSecretPayload {
2027 EndpointRotateWebhookSecretPayload {
2028 environment_id: "local".to_string(),
2029 endpoint_id: endpoint_id.to_string(),
2030 idempotency_key: Some(key.to_string()),
2031 updated_by: "tester".to_string(),
2032 webhook_secret_ref: None,
2033 }
2034 }
2035
2036 #[test]
2037 fn rotate_webhook_secret_changes_devstore_value_and_bumps_generation() {
2038 let (_dir, store, _) = seeded_store_with_bundles(&[]);
2039 let added = add(
2040 &store,
2041 &OpFlags::default(),
2042 Some(add_payload("telegram", "legal-bot", "k1")),
2043 )
2044 .unwrap();
2045 let id = endpoint_id_from(&added);
2046 let before = load_raw_endpoint(&store, &id);
2047 let before_ref = before.webhook_secret_ref.clone().expect("has ref");
2048 let before_value = read_devstore_value(&store, &before_ref);
2049 let before_gen = before.generation;
2050 rotate_webhook_secret(
2051 &store,
2052 &OpFlags::default(),
2053 Some(rotate_payload(&id.to_string(), "k-rotate")),
2054 )
2055 .unwrap();
2056 let after = load_raw_endpoint(&store, &id);
2057 let after_ref = after
2058 .webhook_secret_ref
2059 .as_ref()
2060 .expect("rotated endpoint must have ref");
2061 let after_value = read_devstore_value(&store, after_ref);
2062 assert_eq!(
2063 before_ref, *after_ref,
2064 "rotate must preserve the same URI ref"
2065 );
2066 assert_ne!(
2067 before_value, after_value,
2068 "rotate must produce a different dev-store value"
2069 );
2070 assert!(after_value.len() >= 32, "rotated secret must be ≥32 chars");
2071 assert_eq!(
2072 after.generation,
2073 before_gen + 1,
2074 "rotate must bump generation"
2075 );
2076 }
2077
2078 #[test]
2079 fn local_rotate_rejects_supplied_webhook_ref() {
2080 let (_dir, store, _) = seeded_store_with_bundles(&[]);
2081 let added = add(
2082 &store,
2083 &OpFlags::default(),
2084 Some(add_payload("telegram", "legal-bot", "k1")),
2085 )
2086 .unwrap();
2087 let id = endpoint_id_from(&added);
2088 let mut payload = rotate_payload(&id.to_string(), "k-rotate");
2092 payload.webhook_secret_ref =
2093 Some("secret://local/default/_/messaging-x/webhook_secret".to_string());
2094 let err = rotate_webhook_secret(&store, &OpFlags::default(), Some(payload)).unwrap_err();
2095 assert!(
2096 matches!(err, OpError::InvalidArgument(_)),
2097 "expected InvalidArgument, got {err:?}"
2098 );
2099 }
2100
2101 #[test]
2102 fn idempotent_rotate_preserves_devstore_value() {
2103 let (_dir, store, _) = seeded_store_with_bundles(&[]);
2104 let added = add(
2105 &store,
2106 &OpFlags::default(),
2107 Some(add_payload("telegram", "legal-bot", "k1")),
2108 )
2109 .unwrap();
2110 let id = endpoint_id_from(&added);
2111 rotate_webhook_secret(
2112 &store,
2113 &OpFlags::default(),
2114 Some(rotate_payload(&id.to_string(), "k-rotate")),
2115 )
2116 .unwrap();
2117 let snapshot_1 = load_raw_endpoint(&store, &id);
2120 let ref_1 = snapshot_1.webhook_secret_ref.clone().expect("has ref");
2121 let gen_1 = snapshot_1.generation;
2122 let value_1 = read_devstore_value(&store, &ref_1);
2123 rotate_webhook_secret(
2125 &store,
2126 &OpFlags::default(),
2127 Some(rotate_payload(&id.to_string(), "k-rotate")),
2128 )
2129 .unwrap();
2130 let snapshot_2 = load_raw_endpoint(&store, &id);
2131 let ref_2 = snapshot_2.webhook_secret_ref.clone().expect("has ref");
2132 let gen_2 = snapshot_2.generation;
2133 let value_2 = read_devstore_value(&store, &ref_2);
2134 assert_eq!(ref_1, ref_2, "idempotent replay must preserve the URI ref");
2135 assert_eq!(
2136 value_1, value_2,
2137 "idempotent rotate replay must preserve the dev-store value"
2138 );
2139 assert_eq!(gen_1, gen_2, "idempotent replay must not bump generation");
2140 }
2141
2142 fn add_args_full() -> MessagingEndpointAddArgs {
2145 MessagingEndpointAddArgs {
2146 env: Some("local".into()),
2147 provider_type: Some("telegram".into()),
2148 provider_id: Some("legal-bot".into()),
2149 display_name: Some("Legal Bot".into()),
2150 secret_ref: vec![],
2151 webhook_secret_ref: None,
2152 idempotency_key: Some("k1".into()),
2153 updated_by: Some("alice".into()),
2154 }
2155 }
2156
2157 #[test]
2158 fn add_args_with_all_flags_yields_payload() {
2159 let payload = add_args_full()
2160 .into_payload("add", &OpFlags::default())
2161 .expect("ok")
2162 .expect("some");
2163 assert_eq!(payload.environment_id, "local");
2164 assert_eq!(payload.provider_type, "telegram");
2165 assert_eq!(payload.provider_id, "legal-bot");
2166 assert_eq!(payload.display_name, "Legal Bot");
2167 assert_eq!(payload.idempotency_key, Some("k1".to_string()));
2168 assert_eq!(payload.updated_by, "alice");
2169 assert!(payload.secret_refs.is_empty());
2170 }
2171
2172 #[test]
2173 fn add_args_propagates_secret_refs() {
2174 let mut args = add_args_full();
2175 args.secret_ref = vec![
2176 "secret://local/global/telegram/bot_token".into(),
2177 "secret://local/global/telegram/api_key".into(),
2178 ];
2179 let payload = args
2180 .into_payload("add", &OpFlags::default())
2181 .expect("ok")
2182 .expect("some");
2183 assert_eq!(payload.secret_refs.len(), 2);
2184 assert_eq!(
2185 payload.secret_refs[0],
2186 "secret://local/global/telegram/bot_token"
2187 );
2188 }
2189
2190 #[test]
2191 fn add_args_missing_required_field_is_rejected() {
2192 let mut args = add_args_full();
2193 args.display_name = None;
2194 let err = args.into_payload("add", &OpFlags::default()).unwrap_err();
2195 assert!(matches!(err, OpError::InvalidArgument(_)));
2196 let msg = format!("{err:?}");
2197 assert!(
2198 msg.contains("--display-name"),
2199 "error must name the missing flag: {msg}"
2200 );
2201 }
2202
2203 #[test]
2204 fn add_args_missing_idem_key_is_rejected() {
2205 let mut args = add_args_full();
2206 args.idempotency_key = None;
2207 let err = args.into_payload("add", &OpFlags::default()).unwrap_err();
2208 assert!(matches!(err, OpError::InvalidArgument(_)));
2209 let msg = format!("{err:?}");
2210 assert!(
2211 msg.contains("--idempotency-key"),
2212 "error must name the missing flag: {msg}"
2213 );
2214 }
2215
2216 #[test]
2217 fn add_args_no_flags_returns_none() {
2218 let args = MessagingEndpointAddArgs {
2219 env: None,
2220 provider_type: None,
2221 provider_id: None,
2222 display_name: None,
2223 secret_ref: vec![],
2224 webhook_secret_ref: None,
2225 idempotency_key: None,
2226 updated_by: None,
2227 };
2228 assert!(
2229 args.into_payload("add", &OpFlags::default())
2230 .unwrap()
2231 .is_none()
2232 );
2233 }
2234
2235 #[test]
2236 fn link_bundle_args_with_all_flags_yields_payload() {
2237 let args = MessagingEndpointLinkBundleArgs {
2238 env: Some("local".into()),
2239 endpoint_id: Some("01HXYZ...".into()),
2240 bundle_id: Some("legal".into()),
2241 idempotency_key: Some("k-link".into()),
2242 updated_by: Some("alice".into()),
2243 };
2244 let payload = args
2245 .into_payload("link-bundle", &OpFlags::default())
2246 .expect("ok")
2247 .expect("some");
2248 assert_eq!(payload.environment_id, "local");
2249 assert_eq!(payload.endpoint_id, "01HXYZ...");
2250 assert_eq!(payload.bundle_id, "legal");
2251 }
2252
2253 #[test]
2254 fn link_bundle_args_missing_bundle_is_rejected() {
2255 let args = MessagingEndpointLinkBundleArgs {
2256 env: Some("local".into()),
2257 endpoint_id: Some("01HXYZ".into()),
2258 bundle_id: None,
2259 idempotency_key: Some("k".into()),
2260 updated_by: Some("alice".into()),
2261 };
2262 let err = args
2263 .into_payload("link-bundle", &OpFlags::default())
2264 .unwrap_err();
2265 assert!(matches!(err, OpError::InvalidArgument(_)));
2266 let msg = format!("{err:?}");
2267 assert!(
2268 msg.contains("--bundle-id"),
2269 "error must name the missing flag: {msg}"
2270 );
2271 }
2272
2273 #[test]
2274 fn link_bundle_args_no_flags_returns_none() {
2275 let args = MessagingEndpointLinkBundleArgs {
2276 env: None,
2277 endpoint_id: None,
2278 bundle_id: None,
2279 idempotency_key: None,
2280 updated_by: None,
2281 };
2282 assert!(
2283 args.into_payload("link-bundle", &OpFlags::default())
2284 .unwrap()
2285 .is_none()
2286 );
2287 }
2288
2289 #[test]
2290 fn set_welcome_flow_args_with_all_flags_yields_payload() {
2291 let args = MessagingEndpointSetWelcomeFlowArgs {
2292 env: Some("local".into()),
2293 endpoint_id: Some("01HXYZ".into()),
2294 bundle_id: Some("legal".into()),
2295 pack_id: Some("welcome".into()),
2296 flow_id: Some("hello".into()),
2297 idempotency_key: Some("k-set".into()),
2298 updated_by: Some("alice".into()),
2299 };
2300 let payload = args
2301 .into_payload("set-welcome-flow", &OpFlags::default())
2302 .expect("ok")
2303 .expect("some");
2304 assert_eq!(payload.pack_id, "welcome");
2305 assert_eq!(payload.flow_id, "hello");
2306 }
2307
2308 #[test]
2309 fn set_welcome_flow_args_missing_flow_id_is_rejected() {
2310 let args = MessagingEndpointSetWelcomeFlowArgs {
2311 env: Some("local".into()),
2312 endpoint_id: Some("01HXYZ".into()),
2313 bundle_id: Some("legal".into()),
2314 pack_id: Some("welcome".into()),
2315 flow_id: None,
2316 idempotency_key: Some("k".into()),
2317 updated_by: Some("alice".into()),
2318 };
2319 let err = args
2320 .into_payload("set-welcome-flow", &OpFlags::default())
2321 .unwrap_err();
2322 assert!(matches!(err, OpError::InvalidArgument(_)));
2323 let msg = format!("{err:?}");
2324 assert!(
2325 msg.contains("--flow-id"),
2326 "error must name the missing flag: {msg}"
2327 );
2328 }
2329
2330 #[test]
2331 fn set_welcome_flow_args_no_flags_returns_none() {
2332 let args = MessagingEndpointSetWelcomeFlowArgs {
2333 env: None,
2334 endpoint_id: None,
2335 bundle_id: None,
2336 pack_id: None,
2337 flow_id: None,
2338 idempotency_key: None,
2339 updated_by: None,
2340 };
2341 assert!(
2342 args.into_payload("set-welcome-flow", &OpFlags::default())
2343 .unwrap()
2344 .is_none()
2345 );
2346 }
2347
2348 fn remove_args_full() -> MessagingEndpointRemoveArgs {
2349 MessagingEndpointRemoveArgs {
2350 env: Some("local".into()),
2351 endpoint_id: Some("01HXYZ".into()),
2352 idempotency_key: Some("k-rm".into()),
2353 updated_by: Some("alice".into()),
2354 }
2355 }
2356
2357 #[test]
2358 fn remove_args_with_all_flags_yields_remove_payload() {
2359 let payload = remove_args_full()
2360 .into_remove_payload("remove", &OpFlags::default())
2361 .expect("ok")
2362 .expect("some");
2363 assert_eq!(payload.environment_id, "local");
2364 assert_eq!(payload.endpoint_id, "01HXYZ");
2365 assert_eq!(payload.idempotency_key, Some("k-rm".to_string()));
2366 }
2367
2368 #[test]
2369 fn remove_args_with_all_flags_yields_rotate_payload() {
2370 let payload = remove_args_full()
2371 .into_rotate_payload("rotate-webhook-secret", &OpFlags::default())
2372 .expect("ok")
2373 .expect("some");
2374 assert_eq!(payload.environment_id, "local");
2375 assert_eq!(payload.endpoint_id, "01HXYZ");
2376 }
2377
2378 #[test]
2379 fn remove_args_missing_endpoint_id_is_rejected() {
2380 let mut args = remove_args_full();
2381 args.endpoint_id = None;
2382 let err = args
2383 .into_remove_payload("remove", &OpFlags::default())
2384 .unwrap_err();
2385 assert!(matches!(err, OpError::InvalidArgument(_)));
2386 let msg = format!("{err:?}");
2387 assert!(
2388 msg.contains("--endpoint-id"),
2389 "error must name the missing flag: {msg}"
2390 );
2391 let mut args = remove_args_full();
2394 args.endpoint_id = None;
2395 let err = args
2396 .into_rotate_payload("rotate-webhook-secret", &OpFlags::default())
2397 .unwrap_err();
2398 assert!(matches!(err, OpError::InvalidArgument(_)));
2399 }
2400
2401 #[test]
2402 fn remove_args_no_flags_returns_none() {
2403 let args = MessagingEndpointRemoveArgs {
2404 env: None,
2405 endpoint_id: None,
2406 idempotency_key: None,
2407 updated_by: None,
2408 };
2409 assert!(
2410 args.into_remove_payload("remove", &OpFlags::default())
2411 .unwrap()
2412 .is_none()
2413 );
2414 let args = MessagingEndpointRemoveArgs {
2415 env: None,
2416 endpoint_id: None,
2417 idempotency_key: None,
2418 updated_by: None,
2419 };
2420 assert!(
2421 args.into_rotate_payload("rotate-webhook-secret", &OpFlags::default())
2422 .unwrap()
2423 .is_none()
2424 );
2425 }
2426
2427 #[test]
2433 fn add_partial_flags_rejected_with_missing_list() {
2434 let args = MessagingEndpointAddArgs {
2436 env: Some("prod".into()),
2437 provider_type: Some("telegram".into()),
2438 provider_id: None,
2439 display_name: None,
2440 secret_ref: vec![],
2441 webhook_secret_ref: None,
2442 idempotency_key: None,
2443 updated_by: None,
2444 };
2445 let err = args.into_payload("add", &OpFlags::default()).unwrap_err();
2446 let msg = format!("{err:?}");
2447 assert!(
2448 msg.contains("--provider-id"),
2449 "must list --provider-id: {msg}"
2450 );
2451 assert!(
2452 msg.contains("--display-name"),
2453 "must list --display-name: {msg}"
2454 );
2455 assert!(
2456 msg.contains("--idempotency-key"),
2457 "must list --idempotency-key: {msg}"
2458 );
2459 assert!(
2460 msg.contains("--updated-by"),
2461 "must list --updated-by: {msg}"
2462 );
2463 }
2464
2465 #[test]
2466 fn link_bundle_partial_flags_rejected() {
2467 let args = MessagingEndpointLinkBundleArgs {
2468 env: Some("prod".into()),
2469 endpoint_id: Some("01HXYZ".into()),
2470 bundle_id: None,
2471 idempotency_key: None,
2472 updated_by: Some("alice".into()),
2473 };
2474 let err = args
2475 .into_payload("link-bundle", &OpFlags::default())
2476 .unwrap_err();
2477 let msg = format!("{err:?}");
2478 assert!(msg.contains("--bundle-id"), "must list --bundle-id: {msg}");
2479 assert!(
2480 msg.contains("--idempotency-key"),
2481 "must list --idempotency-key: {msg}"
2482 );
2483 }
2484
2485 #[test]
2486 fn unlink_bundle_partial_flags_rejected() {
2487 let args = MessagingEndpointLinkBundleArgs {
2488 env: Some("prod".into()),
2489 endpoint_id: None,
2490 bundle_id: Some("legal".into()),
2491 idempotency_key: Some("k".into()),
2492 updated_by: None,
2493 };
2494 let err = args
2495 .into_payload("unlink-bundle", &OpFlags::default())
2496 .unwrap_err();
2497 let msg = format!("{err:?}");
2498 assert!(
2499 msg.contains("--endpoint-id"),
2500 "must list --endpoint-id: {msg}"
2501 );
2502 assert!(
2503 msg.contains("--updated-by"),
2504 "must list --updated-by: {msg}"
2505 );
2506 }
2507
2508 #[test]
2509 fn set_welcome_flow_partial_flags_rejected() {
2510 let args = MessagingEndpointSetWelcomeFlowArgs {
2511 env: Some("prod".into()),
2512 endpoint_id: Some("01HXYZ".into()),
2513 bundle_id: Some("legal".into()),
2514 pack_id: None,
2515 flow_id: None,
2516 idempotency_key: Some("k".into()),
2517 updated_by: Some("alice".into()),
2518 };
2519 let err = args
2520 .into_payload("set-welcome-flow", &OpFlags::default())
2521 .unwrap_err();
2522 let msg = format!("{err:?}");
2523 assert!(msg.contains("--pack-id"), "must list --pack-id: {msg}");
2524 assert!(msg.contains("--flow-id"), "must list --flow-id: {msg}");
2525 }
2526
2527 #[test]
2528 fn remove_partial_flags_rejected() {
2529 let args = MessagingEndpointRemoveArgs {
2530 env: Some("prod".into()),
2531 endpoint_id: Some("01HXYZ".into()),
2532 idempotency_key: None,
2533 updated_by: None,
2534 };
2535 let err = args
2536 .into_remove_payload("remove", &OpFlags::default())
2537 .unwrap_err();
2538 let msg = format!("{err:?}");
2539 assert!(
2540 msg.contains("--idempotency-key"),
2541 "must list --idempotency-key: {msg}"
2542 );
2543 assert!(
2544 msg.contains("--updated-by"),
2545 "must list --updated-by: {msg}"
2546 );
2547 }
2548
2549 #[test]
2550 fn rotate_webhook_secret_partial_flags_rejected() {
2551 let args = MessagingEndpointRemoveArgs {
2552 env: Some("prod".into()),
2553 endpoint_id: None,
2554 idempotency_key: Some("k".into()),
2555 updated_by: Some("alice".into()),
2556 };
2557 let err = args
2558 .into_rotate_payload("rotate-webhook-secret", &OpFlags::default())
2559 .unwrap_err();
2560 let msg = format!("{err:?}");
2561 assert!(
2562 msg.contains("--endpoint-id"),
2563 "must list --endpoint-id: {msg}"
2564 );
2565 }
2566
2567 fn flags_with_answers() -> OpFlags {
2570 OpFlags {
2571 schema_only: false,
2572 answers: Some(std::path::PathBuf::from("stale.json")),
2573 }
2574 }
2575
2576 #[test]
2577 fn add_inline_plus_answers_is_rejected() {
2578 let err = remove_args_full()
2582 .into_remove_payload("remove", &flags_with_answers())
2583 .unwrap_err();
2584 let msg = format!("{err:?}");
2585 assert!(
2586 msg.contains("mutually exclusive"),
2587 "must mention mutual exclusion: {msg}"
2588 );
2589 }
2590
2591 #[test]
2592 fn rotate_inline_plus_answers_is_rejected() {
2593 let err = remove_args_full()
2594 .into_rotate_payload("rotate-webhook-secret", &flags_with_answers())
2595 .unwrap_err();
2596 assert!(matches!(err, OpError::InvalidArgument(_)));
2597 }
2598
2599 #[test]
2600 fn add_args_inline_plus_answers_is_rejected() {
2601 let err = add_args_full()
2602 .into_payload("add", &flags_with_answers())
2603 .unwrap_err();
2604 assert!(matches!(err, OpError::InvalidArgument(_)));
2605 }
2606
2607 #[test]
2608 fn no_inline_flags_plus_answers_is_ok() {
2609 let args = MessagingEndpointRemoveArgs {
2611 env: None,
2612 endpoint_id: None,
2613 idempotency_key: None,
2614 updated_by: None,
2615 };
2616 assert!(
2617 args.into_remove_payload("remove", &flags_with_answers())
2618 .unwrap()
2619 .is_none()
2620 );
2621 }
2622
2623 #[test]
2626 fn cli_derived_add_payload_drives_verb_e2e() {
2627 let (_dir, store, _) = seeded_store_with_bundles(&[]);
2628 let payload = MessagingEndpointAddArgs {
2629 env: Some("local".into()),
2630 provider_type: Some("teams".into()),
2631 provider_id: Some("cli-bot".into()),
2632 display_name: Some("CLI Bot".into()),
2633 secret_ref: vec![],
2634 webhook_secret_ref: None,
2635 idempotency_key: Some("cli-add-1".into()),
2636 updated_by: Some("cli".into()),
2637 }
2638 .into_payload("add", &OpFlags::default())
2639 .expect("ok")
2640 .expect("some");
2641 let outcome = add(&store, &OpFlags::default(), Some(payload)).unwrap();
2642 assert_eq!(outcome.result["provider_type"], "teams");
2643 assert_eq!(outcome.result["provider_id"], "cli-bot");
2644 assert_eq!(outcome.result["display_name"], "CLI Bot");
2645 }
2646}