1mod cipher;
8mod gate;
9
10use std::{sync::Arc, time::Duration};
11
12use bitwarden_core::{Client, OrganizationId};
13use bitwarden_generators::GeneratorClientsExt as _;
14use bitwarden_pam::{
15 RotationFailureCode, RotationSyncState, SessionTerminationOutcome, VerifiedJob,
16};
17use bitwarden_threading::cancellation_token::CancellationToken;
18use chrono::Utc;
19pub(crate) use gate::LastOk;
20use gate::{Gate, datetime_to_instant};
21use tokio::time::Instant;
22use uuid::Uuid;
23
24use super::retry::{GatedOutcome, RetryCfg, with_retries, with_retries_gated};
25use crate::{
26 api::{AccessConnectorClient, AccessConnectorClientExt as _, models::ApiError},
27 auth::session::{SessionLost, SessionManager, SessionPhase},
28 error::SafeDetail,
29 integrations::{Integration, IntegrationError, IntegrationRegistry, RotateContext},
30 resolver::{CredentialResolver, ResolveError},
31};
32
33#[derive(Debug, Clone)]
35pub(crate) enum AbortReason {
36 LeaseExpired,
38 SessionLost(SessionLost),
40 Cancelled,
42 UnknownAttempt,
44 ReportFailed,
46}
47
48enum Halt {
50 Report {
51 code: RotationFailureCode,
52 detail: Option<SafeDetail>,
53 sync: RotationSyncState,
54 },
55 Abort(AbortReason),
56}
57
58impl Halt {
59 fn report(code: RotationFailureCode, sync: RotationSyncState) -> Self {
60 Self::Report {
61 code,
62 detail: None,
63 sync,
64 }
65 }
66
67 fn report_detail(
68 code: RotationFailureCode,
69 detail: SafeDetail,
70 sync: RotationSyncState,
71 ) -> Self {
72 Self::Report {
73 code,
74 detail: Some(detail),
75 sync,
76 }
77 }
78}
79
80impl From<AbortReason> for Halt {
81 fn from(reason: AbortReason) -> Self {
82 Self::Abort(reason)
83 }
84}
85
86pub(crate) struct Verified(());
88
89pub(crate) struct CipherWritten(());
91
92#[derive(Debug)]
94pub(crate) enum ExecutionResult {
95 Reported,
97 Unreported(AbortReason),
99}
100
101pub(crate) struct ExecutionContext {
103 pub(crate) client: Client,
104 pub(crate) session: Arc<SessionManager>,
105 pub(crate) integrations: Arc<IntegrationRegistry>,
106 pub(crate) resolver: Arc<dyn CredentialResolver>,
107 pub(crate) organization_id: OrganizationId,
109 pub(crate) retry_cfg: RetryCfg,
110 pub(crate) offline_grace: Duration,
112 pub(crate) last_ok: LastOk,
114 pub(crate) cancel: CancellationToken,
115}
116
117#[derive(Clone, Copy)]
119enum TargetStep {
120 Rotate,
121 Verify,
122 TerminateSessions,
123}
124
125impl TargetStep {
126 async fn run(
127 self,
128 integration: &dyn Integration,
129 ctx: &RotateContext,
130 ) -> Result<(), IntegrationError> {
131 match self {
132 TargetStep::Rotate => integration.rotate(ctx).await,
133 TargetStep::Verify => integration.verify(ctx).await,
134 TargetStep::TerminateSessions => integration.terminate_sessions(ctx).await,
135 }
136 }
137
138 fn failure_sync(self, err: &IntegrationError) -> RotationSyncState {
140 match self {
141 TargetStep::Rotate => RotationSyncState::from(err.effect),
142 TargetStep::Verify | TargetStep::TerminateSessions => RotationSyncState::TargetUpdated,
144 }
145 }
146}
147
148struct Attempt<'a> {
150 job: &'a VerifiedJob,
151 ctx: &'a ExecutionContext,
152 api: &'a AccessConnectorClient,
153 attempt_id: Uuid,
154 execute_by: Instant,
155}
156
157pub(crate) async fn execute(job: VerifiedJob, ctx: &ExecutionContext) -> ExecutionResult {
160 let attempt_id = job.attempt_id();
161 let api = ctx.client.access_connector();
162
163 tracing::info!(
164 attempt_id = %attempt_id,
165 job_id = %job.job_id(),
166 cipher_id = %job.cipher_id(),
167 target_system_name = %job.target_system_name(),
168 "starting rotation execution"
169 );
170
171 let attempt = Attempt {
172 job: &job,
173 ctx,
174 api: &api,
175 attempt_id,
176 execute_by: datetime_to_instant(job.execute_by()),
177 };
178
179 match attempt.run().await {
180 Ok(()) => ExecutionResult::Reported,
181 Err(Halt::Report { code, detail, sync }) => {
182 report_failure_absorb(&api, attempt_id, code, detail, sync).await;
183 ExecutionResult::Reported
184 }
185 Err(Halt::Abort(reason)) => ExecutionResult::Unreported(reason),
186 }
187}
188
189impl Attempt<'_> {
190 async fn run(&self) -> Result<(), Halt> {
192 let Self {
193 job,
194 ctx,
195 attempt_id,
196 ..
197 } = *self;
198
199 self.check_session_live().await?;
200 self.pre_flight().await?;
201
202 tracing::info!(attempt_id = %attempt_id, "pre-flight cipher check passed");
203
204 let creds = ctx
205 .resolver
206 .resolve(job.target_system_id(), job.kind())
207 .await
208 .map_err(|ResolveError::Missing(names)| {
209 Halt::report_detail(
210 RotationFailureCode::CredentialsUnresolved,
211 SafeDetail::from_missing_vars(&names),
212 RotationSyncState::TargetUnchanged,
213 )
214 })?;
215
216 tracing::info!(attempt_id = %attempt_id, "step 1: credentials resolved");
217
218 let new_password = self.generate_password()?;
219
220 tracing::info!(attempt_id = %attempt_id, "step 2: password generated");
221
222 let rotation_started_at = Utc::now();
223
224 let integration = ctx.integrations.get(job.kind()).ok_or_else(|| {
225 Halt::report(
226 RotationFailureCode::UnsupportedKind,
227 RotationSyncState::TargetUnchanged,
228 )
229 })?;
230
231 let rotate_ctx = RotateContext {
232 target_system_id: job.target_system_id(),
233 account_identity: job.account_identity().to_owned(),
234 new_password,
235 creds,
236 rotation_started_at,
237 };
238
239 self.run_required_step(&*integration, &rotate_ctx, TargetStep::Rotate)
240 .await?;
241
242 tracing::info!(
243 attempt_id = %attempt_id,
244 kind = ?job.kind(),
245 "step 3: target rotate succeeded"
246 );
247
248 self.run_required_step(&*integration, &rotate_ctx, TargetStep::Verify)
249 .await?;
250
251 tracing::info!(attempt_id = %attempt_id, "step 4: verify succeeded");
252 let verified = Verified(());
253
254 let cipher_written = self.write_cipher(&rotate_ctx.new_password).await?;
255
256 let termination = self.terminate_sessions(&*integration, &rotate_ctx).await?;
257
258 self.report_success(termination, verified, cipher_written)
259 .await?;
260
261 tracing::info!(
262 attempt_id = %attempt_id,
263 termination = ?termination,
264 "step 7: rotation succeeded and reported"
265 );
266 Ok(())
267 }
268
269 async fn check_session_live(&self) -> Result<(), Halt> {
271 let phase = self.ctx.session.phase().await;
272 if matches!(phase, SessionPhase::Revoked | SessionPhase::Closed) {
273 return Err(Halt::report(
275 RotationFailureCode::NoActiveSession,
276 RotationSyncState::TargetUnchanged,
277 ));
278 }
279 Ok(())
280 }
281
282 fn generate_password(&self) -> Result<String, Halt> {
284 let invalid_policy = || {
285 Halt::report(
286 RotationFailureCode::InvalidPolicy,
287 RotationSyncState::TargetUnchanged,
288 )
289 };
290 let gen_req = self.job.generator_request().map_err(|_| invalid_policy())?;
291 self.ctx
292 .client
293 .generator()
294 .password(gen_req)
295 .map_err(|_| invalid_policy())
296 }
297
298 async fn run_target_step(
300 &self,
301 integration: &dyn Integration,
302 rotate_ctx: &RotateContext,
303 step: TargetStep,
304 ) -> GatedOutcome<(), IntegrationError, AbortReason> {
305 let ctx = self.ctx;
306 let gate = Gate {
307 session: &ctx.session,
308 execute_by: self.execute_by,
309 offline_grace: ctx.offline_grace,
310 last_ok: &*ctx.last_ok,
311 cancel: &ctx.cancel,
312 };
313
314 with_retries_gated(
315 &ctx.retry_cfg,
316 || gate.check(),
317 || async move {
318 step.run(integration, rotate_ctx)
319 .await
320 .map_err(|e| (e.retry, e))
321 },
322 )
323 .await
324 }
325
326 async fn run_required_step(
328 &self,
329 integration: &dyn Integration,
330 rotate_ctx: &RotateContext,
331 step: TargetStep,
332 ) -> Result<(), Halt> {
333 match self.run_target_step(integration, rotate_ctx, step).await {
334 GatedOutcome::Ok(()) => Ok(()),
335 GatedOutcome::Aborted(reason) => Err(reason.into()),
336 GatedOutcome::Failed(err) => {
337 let sync = step.failure_sync(&err);
338 Err(Halt::report_detail(err.code, err.detail, sync))
339 }
340 }
341 }
342
343 async fn terminate_sessions(
346 &self,
347 integration: &dyn Integration,
348 rotate_ctx: &RotateContext,
349 ) -> Result<SessionTerminationOutcome, Halt> {
350 if !self.job.terminate_sessions() {
351 return Ok(SessionTerminationOutcome::NotRequested);
352 }
353
354 let attempt_id = self.attempt_id;
355 let outcome = self
356 .run_target_step(integration, rotate_ctx, TargetStep::TerminateSessions)
357 .await;
358
359 Ok(match outcome {
360 GatedOutcome::Ok(()) => {
361 tracing::info!(attempt_id = %attempt_id, "step 6: session termination succeeded");
362 SessionTerminationOutcome::Terminated
363 }
364 GatedOutcome::Aborted(AbortReason::SessionLost(l)) => {
365 return Err(AbortReason::SessionLost(l).into());
366 }
367 GatedOutcome::Aborted(reason) => {
368 tracing::warn!(
370 attempt_id = %attempt_id,
371 abort_reason = ?reason,
372 "step 6: session termination aborted (reporting term_failed)"
373 );
374 SessionTerminationOutcome::TermFailed
375 }
376 GatedOutcome::Failed(err) => {
377 tracing::warn!(
378 attempt_id = %attempt_id,
379 "step 6: session termination failed: {err}; reporting term_failed"
380 );
381 SessionTerminationOutcome::TermFailed
382 }
383 })
384 }
385
386 async fn report_success(
389 &self,
390 termination: SessionTerminationOutcome,
391 _verified: Verified,
392 _cipher_written: CipherWritten,
393 ) -> Result<(), AbortReason> {
394 let Self {
395 api, attempt_id, ..
396 } = *self;
397 with_retries(&self.ctx.retry_cfg, None, || async move {
398 match api.report_success(attempt_id, termination).await {
399 Err(ApiError::Rejected { .. } | ApiError::UnknownAttempt) => {
401 tracing::warn!(
402 attempt_id = %attempt_id,
403 "success report rejected/unknown by server; treating as reported"
404 );
405 Ok(())
406 }
407 result => result.map_err(|e| (e.retry_class(), e)),
408 }
409 })
410 .await
411 .map_err(|e| match e {
412 ApiError::SessionLost(l) => AbortReason::SessionLost(l),
413 _ => AbortReason::ReportFailed,
414 })
415 }
416}
417
418fn abort_from_api(e: &ApiError) -> Option<AbortReason> {
420 match e {
421 ApiError::UnknownAttempt => Some(AbortReason::UnknownAttempt),
422 ApiError::SessionLost(l) => Some(AbortReason::SessionLost(*l)),
423 _ => None,
424 }
425}
426
427fn halt_from_api(e: ApiError, sync: RotationSyncState) -> Halt {
429 match abort_from_api(&e) {
430 Some(reason) => Halt::Abort(reason),
431 None => Halt::report(RotationFailureCode::Internal, sync),
432 }
433}
434
435pub(crate) async fn report_failure_absorb(
438 api: &AccessConnectorClient,
439 attempt_id: Uuid,
440 code: RotationFailureCode,
441 detail: Option<SafeDetail>,
442 sync_state: RotationSyncState,
443) {
444 tracing::warn!(
445 attempt_id = %attempt_id,
446 failure_code = ?code,
447 sync_state = ?sync_state,
448 detail = detail.as_ref().map(SafeDetail::as_str).unwrap_or(""),
449 "rotation failed"
450 );
451 if let Err(e) = api
452 .report_failure(attempt_id, code, detail, sync_state)
453 .await
454 {
455 tracing::warn!(attempt_id = %attempt_id, "failure report itself failed: {e}");
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use std::sync::Mutex;
462
463 use async_trait::async_trait;
464 use bitwarden_core::key_management::KeySlotIds;
465 use bitwarden_crypto::KeyStore;
466 use wiremock::ResponseTemplate;
467
468 use super::*;
469 use crate::{
470 error::Retry,
471 integrations::TargetEffect,
472 test_support::{
473 KEYED_LOGIN_ID, keyed_login_json, mount_identity_ok, test_org_id, test_token,
474 },
475 };
476
477 struct MockIntegration {
478 rotate_result: Result<(), IntegrationError>,
479 verify_result: Result<(), IntegrationError>,
480 terminate_result: Result<(), IntegrationError>,
481 rotate_calls: Arc<Mutex<u32>>,
482 }
483
484 impl MockIntegration {
485 fn always_ok() -> Self {
486 Self {
487 rotate_result: Ok(()),
488 verify_result: Ok(()),
489 terminate_result: Ok(()),
490 rotate_calls: Arc::new(Mutex::new(0)),
491 }
492 }
493 }
494
495 #[async_trait]
496 impl Integration for MockIntegration {
497 async fn rotate(&self, _ctx: &RotateContext) -> Result<(), IntegrationError> {
498 *self.rotate_calls.lock().unwrap() += 1;
499 replay(&self.rotate_result)
500 }
501
502 async fn verify(&self, _ctx: &RotateContext) -> Result<(), IntegrationError> {
503 replay(&self.verify_result)
504 }
505
506 async fn terminate_sessions(&self, _ctx: &RotateContext) -> Result<(), IntegrationError> {
507 replay(&self.terminate_result)
508 }
509 }
510
511 fn replay(r: &Result<(), IntegrationError>) -> Result<(), IntegrationError> {
513 r.as_ref().map(|_| ()).map_err(|e| {
514 IntegrationError::new(e.retry, e.effect, e.code, SafeDetail::from_kind("mock"))
515 })
516 }
517
518 pub(super) struct Run {
520 pub(super) result: ExecutionResult,
521 pub(super) rotate_calls: u32,
522 requests: Vec<wiremock::Request>,
523 }
524
525 impl Run {
526 pub(super) fn requests_to(&self, method: &str, suffix: &str) -> Vec<&wiremock::Request> {
527 self.requests
528 .iter()
529 .filter(|r| r.method.as_str() == method && r.url.path().ends_with(suffix))
530 .collect()
531 }
532
533 pub(super) fn failure_body(&self) -> serde_json::Value {
534 let reqs = self.requests_to("POST", "/failure");
535 assert_eq!(reqs.len(), 1, "expected exactly one failure report");
536 serde_json::from_slice(&reqs[0].body).expect("parse failure body")
537 }
538 }
539
540 pub(super) struct Fixture {
544 api_server: wiremock::MockServer,
545 _identity_server: wiremock::MockServer,
547 pub(super) store: KeyStore<KeySlotIds>,
549 ctx: ExecutionContext,
550 job: VerifiedJob,
551 api: AccessConnectorClient,
552 rotate_calls: Arc<Mutex<u32>>,
553 }
554
555 impl Fixture {
556 pub(super) async fn new(
559 get: impl wiremock::Respond + 'static,
560 put: impl wiremock::Respond + 'static,
561 ) -> Self {
562 Self::with_integration(MockIntegration::always_ok(), get, put).await
563 }
564
565 async fn with_integration(
567 integ: MockIntegration,
568 get: impl wiremock::Respond + 'static,
569 put: impl wiremock::Respond + 'static,
570 ) -> Self {
571 use bitwarden_pam::WorkSnapshot;
572 use bitwarden_pam_sealed::SealedTargetSystemKind;
573 use tokio::sync::watch;
574 use wiremock::{
575 Mock, MockServer,
576 matchers::{method, path},
577 };
578
579 use crate::{
580 api::{REQUEST_TIMEOUT, build_sdk_client},
581 auth::identity::IdentityClient,
582 resolver::ResolvedCredentials,
583 test_support::{sample_config, sample_target, sealed_pair},
584 };
585
586 let identity_server = MockServer::start().await;
587 let api_server = MockServer::start().await;
588
589 mount_identity_ok(&identity_server, None).await;
591
592 let attempt_id = Uuid::new_v4();
593 let job_id = Uuid::new_v4();
594 let target_system_id = Uuid::new_v4();
595
596 let (tx, _rx) = watch::channel(Instant::now());
598 let (client, handler) = build_sdk_client(
599 api_server.uri(),
600 "http://identity.invalid".to_owned(),
601 REQUEST_TIMEOUT,
602 tx,
603 );
604 let store = handler.key_store().expect("client key store");
605 let identity = IdentityClient::new(identity_server.uri()).unwrap();
606 let session = SessionManager::new(identity, test_token(), store.clone())
607 .await
608 .unwrap();
609 handler.set_session(Arc::clone(&session));
610
611 let cipher_path = format!("/access-connectors/rotation/attempts/{attempt_id}/cipher");
612 let cipher_id = KEYED_LOGIN_ID;
613 Mock::given(method("GET"))
614 .and(path(cipher_path.clone()))
615 .respond_with(get)
616 .mount(&api_server)
617 .await;
618 Mock::given(method("PUT"))
619 .and(path(cipher_path))
620 .respond_with(put)
621 .mount(&api_server)
622 .await;
623 for report in ["failure", "success"] {
624 Mock::given(method("POST"))
625 .and(path(format!(
626 "/access-connectors/rotation/attempts/{attempt_id}/{report}"
627 )))
628 .respond_with(ResponseTemplate::new(200))
629 .mount(&api_server)
630 .await;
631 }
632
633 struct AlwaysOkResolver;
634 #[async_trait::async_trait]
635 impl CredentialResolver for AlwaysOkResolver {
636 async fn resolve(
637 &self,
638 _id: Uuid,
639 _kind: SealedTargetSystemKind,
640 ) -> Result<ResolvedCredentials, ResolveError> {
641 Ok(ResolvedCredentials::new())
642 }
643 }
644
645 let rotate_calls = Arc::clone(&integ.rotate_calls);
646 let mut registry = IntegrationRegistry::new();
647 registry.register(SealedTargetSystemKind::CustomScript, Arc::new(integ));
648
649 let (target_blob, config_blob) = sealed_pair(
650 &store,
651 sample_target(target_system_id),
652 sample_config(target_system_id, cipher_id),
653 );
654
655 let last_ok_time = Arc::new(Mutex::new(Instant::now()));
656 let ctx = ExecutionContext {
657 client,
658 session: Arc::clone(&session),
659 integrations: Arc::new(registry),
660 resolver: Arc::new(AlwaysOkResolver),
661 organization_id: test_org_id(),
662 retry_cfg: RetryCfg {
663 max_retry_attempts: 1,
664 retry_base_delay: Duration::from_millis(0),
665 },
666 offline_grace: Duration::from_secs(60),
667 last_ok: Arc::new(move || *last_ok_time.lock().unwrap()),
668 cancel: CancellationToken::new(),
669 };
670
671 let snapshot = WorkSnapshot {
672 attempt_id,
673 job_id,
674 target_system_id,
675 target_system_name: "test".to_owned(),
676 cipher_id,
677 target_system_sealed_data: Some(target_blob.0),
678 target_system_wrapped_key: Some(target_blob.1),
679 rotation_config_sealed_data: Some(config_blob.0),
680 rotation_config_wrapped_key: Some(config_blob.1),
681 execute_by: Utc::now() + chrono::Duration::minutes(5),
682 };
683 let job = snapshot
684 .verify(&store, test_org_id())
685 .expect("well-formed claim");
686 let api = ctx.client.access_connector();
687
688 Self {
689 api_server,
690 _identity_server: identity_server,
691 store,
692 ctx,
693 job,
694 api,
695 rotate_calls,
696 }
697 }
698
699 pub(super) fn attempt(&self) -> Attempt<'_> {
701 Attempt {
702 job: &self.job,
703 ctx: &self.ctx,
704 api: &self.api,
705 attempt_id: self.job.attempt_id(),
706 execute_by: datetime_to_instant(self.job.execute_by()),
707 }
708 }
709
710 pub(super) async fn requests_to(
712 &self,
713 method: &str,
714 suffix: &str,
715 ) -> Vec<wiremock::Request> {
716 self.api_server
717 .received_requests()
718 .await
719 .unwrap()
720 .into_iter()
721 .filter(|r| r.method.as_str() == method && r.url.path().ends_with(suffix))
722 .collect()
723 }
724
725 pub(super) async fn run(self) -> Run {
727 let result = execute(self.job, &self.ctx).await;
728 let rotate_calls = *self.rotate_calls.lock().unwrap();
729 Run {
730 result,
731 rotate_calls,
732 requests: self.api_server.received_requests().await.unwrap(),
733 }
734 }
735 }
736
737 pub(super) fn json_response(body: serde_json::Value) -> ResponseTemplate {
738 ResponseTemplate::new(200)
739 .set_body_json(body)
740 .insert_header("content-type", "application/json")
741 }
742
743 async fn run_execute(get: ResponseTemplate) -> Run {
745 Fixture::new(get, ResponseTemplate::new(200))
746 .await
747 .run()
748 .await
749 }
750
751 fn failing(effect: TargetEffect, code: RotationFailureCode) -> IntegrationError {
753 IntegrationError::new(
754 Retry::Permanent,
755 effect,
756 code,
757 SafeDetail::from_kind("mock"),
758 )
759 }
760
761 async fn run_with_integration(integ: MockIntegration) -> Run {
763 Fixture::with_integration(
764 integ,
765 json_response(keyed_login_json()),
766 ResponseTemplate::new(200),
767 )
768 .await
769 .run()
770 .await
771 }
772
773 #[tokio::test]
775 async fn rotation_succeeds_end_to_end() {
776 let run = run_execute(json_response(keyed_login_json())).await;
777
778 assert!(matches!(run.result, ExecutionResult::Reported));
779 assert_eq!(run.rotate_calls, 1);
780 assert_eq!(run.requests_to("PUT", "/cipher").len(), 1);
781 assert_eq!(run.requests_to("POST", "/success").len(), 1);
782 assert!(run.requests_to("POST", "/failure").is_empty());
783 }
784
785 #[tokio::test]
787 async fn preflight_cipher_id_mismatch_rejects_before_any_integration_call() {
788 let mut json = keyed_login_json();
789 json["id"] = Uuid::new_v4().to_string().into();
790 let run = run_execute(json_response(json)).await;
791
792 assert!(matches!(run.result, ExecutionResult::Reported));
793 assert_eq!(run.rotate_calls, 0, "rotate must not run");
794 assert!(run.requests_to("PUT", "/cipher").is_empty());
795 assert!(run.requests_to("POST", "/success").is_empty());
796 let body = run.failure_body();
797 assert_eq!(body["errorCode"], serde_json::json!("sealed_data_rejected"));
798 assert_eq!(body["syncState"], serde_json::json!(0));
799 assert_eq!(
800 body["detail"],
801 SafeDetail::from_seal_rejection("pre_flight_cipher_id_mismatch").as_str()
802 );
803 }
804
805 #[tokio::test]
808 async fn preflight_unknown_attempt_aborts_unreported() {
809 let run = run_execute(ResponseTemplate::new(404)).await;
810
811 assert!(matches!(
812 run.result,
813 ExecutionResult::Unreported(AbortReason::UnknownAttempt)
814 ));
815 assert_eq!(run.rotate_calls, 0, "rotate must not run");
816 assert!(run.requests_to("POST", "/failure").is_empty());
817 assert!(run.requests_to("POST", "/success").is_empty());
818 }
819
820 #[tokio::test]
822 async fn rotate_failure_reports_code_and_effect_sync_state() {
823 let run = run_with_integration(MockIntegration {
824 rotate_result: Err(failing(
825 TargetEffect::Unknown,
826 RotationFailureCode::ScriptFailed,
827 )),
828 ..MockIntegration::always_ok()
829 })
830 .await;
831
832 assert!(matches!(run.result, ExecutionResult::Reported));
833 assert_eq!(run.rotate_calls, 1);
834 assert!(run.requests_to("PUT", "/cipher").is_empty());
835 assert!(run.requests_to("POST", "/success").is_empty());
836 let body = run.failure_body();
837 assert_eq!(body["errorCode"], "script_failed");
838 assert_eq!(body["syncState"], serde_json::json!(2));
839 }
840
841 #[tokio::test]
844 async fn verify_failure_reports_target_updated_ignoring_effect() {
845 let run = run_with_integration(MockIntegration {
846 verify_result: Err(failing(
847 TargetEffect::NotApplied,
848 RotationFailureCode::TargetUnreachable,
849 )),
850 ..MockIntegration::always_ok()
851 })
852 .await;
853
854 assert!(matches!(run.result, ExecutionResult::Reported));
855 assert_eq!(run.rotate_calls, 1);
856 assert!(run.requests_to("PUT", "/cipher").is_empty());
857 assert!(run.requests_to("POST", "/success").is_empty());
858 let body = run.failure_body();
859 assert_eq!(body["errorCode"], "target_unreachable");
860 assert_eq!(body["syncState"], serde_json::json!(1));
861 }
862
863 #[tokio::test]
866 async fn terminate_sessions_failure_reports_success_with_term_failed() {
867 let run = run_with_integration(MockIntegration {
868 terminate_result: Err(failing(
869 TargetEffect::NotApplied,
870 RotationFailureCode::ScriptFailed,
871 )),
872 ..MockIntegration::always_ok()
873 })
874 .await;
875
876 assert!(matches!(run.result, ExecutionResult::Reported));
877 assert!(run.requests_to("POST", "/failure").is_empty());
878 assert_eq!(run.requests_to("PUT", "/cipher").len(), 1);
879 let successes = run.requests_to("POST", "/success");
880 assert_eq!(successes.len(), 1);
881 let body: serde_json::Value = serde_json::from_slice(&successes[0].body).unwrap();
882 assert_eq!(body["sessionTermination"], serde_json::json!(2));
883 }
884}