Skip to main content

bitwarden_access_connector/executor/rotation/
mod.rs

1//! Core rotation execution pipeline.
2//!
3//! A `Revoked` or `Closed` session fails the attempt up front; an `Expired` one is refreshed by
4//! the gate. `execute_by` bounds only the target-side steps (rotate, verify, terminate sessions),
5//! not the server-side cipher write and report.
6
7mod 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/// Why a gate aborted the rotation without reporting. The server's release sweep reclaims the job.
34#[derive(Debug, Clone)]
35pub(crate) enum AbortReason {
36    /// The claim's `execute_by` deadline has passed.
37    LeaseExpired,
38    /// The session was revoked or closed.
39    SessionLost(SessionLost),
40    /// Shutdown was requested.
41    Cancelled,
42    /// The server no longer knows the attempt (a 404 on an attempt route).
43    UnknownAttempt,
44    /// The success report could not be delivered.
45    ReportFailed,
46}
47
48/// Why an attempt stopped before success: report a failure, or abort unreported.
49enum 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
86/// Proof that verify succeeded.
87pub(crate) struct Verified(());
88
89/// Proof that the cipher write succeeded.
90pub(crate) struct CipherWritten(());
91
92/// The outcome of a single [`execute`] call.
93#[derive(Debug)]
94pub(crate) enum ExecutionResult {
95    /// An outcome was reported to the server.
96    Reported,
97    /// Aborted before reporting; the server's release sweep reclaims the job.
98    Unreported(AbortReason),
99}
100
101/// Inputs to [`execute`].
102pub(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    /// The organisation that owns the cipher.
108    pub(crate) organization_id: OrganizationId,
109    pub(crate) retry_cfg: RetryCfg,
110    /// How long without server contact before the gate pauses target-side steps.
111    pub(crate) offline_grace: Duration,
112    /// The instant of the last successful server contact.
113    pub(crate) last_ok: LastOk,
114    pub(crate) cancel: CancellationToken,
115}
116
117/// A gated, target-side integration step.
118#[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    /// The sync state to report when this step fails permanently.
139    fn failure_sync(self, err: &IntegrationError) -> RotationSyncState {
140        match self {
141            TargetStep::Rotate => RotationSyncState::from(err.effect),
142            // These run after the target was rotated, so the cipher is now stale.
143            TargetStep::Verify | TargetStep::TerminateSessions => RotationSyncState::TargetUpdated,
144        }
145    }
146}
147
148/// One attempt's inputs, shared by its steps.
149struct Attempt<'a> {
150    job: &'a VerifiedJob,
151    ctx: &'a ExecutionContext,
152    api: &'a AccessConnectorClient,
153    attempt_id: Uuid,
154    execute_by: Instant,
155}
156
157/// Runs one rotation attempt: a cipher pre-flight check, then resolve, generate, rotate, verify,
158/// write cipher, terminate sessions and report.
159pub(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    /// The attempt pipeline; each step returns a [`Halt`] to stop it.
191    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    /// Fails the attempt up front when the session is terminal.
270    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            // Best effort: the session is terminal, so the report may fail.
274            return Err(Halt::report(
275                RotationFailureCode::NoActiveSession,
276                RotationSyncState::TargetUnchanged,
277            ));
278        }
279        Ok(())
280    }
281
282    /// Generates the new password from the claim's policy.
283    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    /// Runs one gated target-side step with retries.
299    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    /// Runs a step whose failure fails the attempt, reporting the step's sync state.
327    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    /// Ends the account's sessions when the claim asks for it. Termination never fails the
344    /// rotation, but a lost session blocks the report.
345    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                // Lease expired or cancelled: report success with term_failed.
369                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    /// Reports a successful rotation. The proof tokens make skipping verify or the cipher write a
387    /// compile error.
388    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                // A 409 or 404 on report is final.
400                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
418/// The abort for an error that ends the attempt unreported, if it is one.
419fn 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
427/// Maps an API error to a halt: abort when the attempt or session is gone, else report `Internal`.
428fn 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
435/// Reports a rotation failure, absorbing report errors. Also used by the claim loop, before an
436/// `ExecutionContext` exists.
437pub(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    /// A fresh copy of a configured result, since `IntegrationError` is not `Clone`.
512    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    /// What a [`Fixture::run`] call observed.
519    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    /// One rotation attempt set up against mock servers: the client, an authenticated session, the
541    /// execution context and the verified claim. Run it whole with [`Fixture::run`], or take an
542    /// [`Attempt`] to drive a single step.
543    pub(super) struct Fixture {
544        api_server: wiremock::MockServer,
545        // Kept alive for the session's token refreshes.
546        _identity_server: wiremock::MockServer,
547        /// The client's key store, holding the org key the session installed.
548        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        /// A fixture for a claim naming the keyed login, with an always-succeeding integration.
557        /// `get` serves every `GET /cipher`; `put` serves `PUT /cipher`.
558        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        /// [`Fixture::new`] against a configured `integ`.
566        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            // The session installs the org key the cipher vectors are encrypted under.
590            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            // The client's key store, which the session installs the org key into.
597            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        /// The attempt, to run a single step against.
700        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        /// The requests the API server received with `method` and a path ending in `suffix`.
711        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        /// Runs the whole pipeline through [`execute`].
726        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    /// Runs a rotation of the cipher served by `get`. The PUT succeeds.
744    async fn run_execute(get: ResponseTemplate) -> Run {
745        Fixture::new(get, ResponseTemplate::new(200))
746            .await
747            .run()
748            .await
749    }
750
751    /// A permanent failure, so no retry waits.
752    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    /// Runs a rotation of the keyed login against `integ`, with the cipher PUT succeeding.
762    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    /// A rotation runs every step and reports success once.
774    #[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    /// A cipher that fails the pre-flight is reported before any integration call.
786    #[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    /// A 404 on the pre-flight `GET /cipher` means the server no longer knows the attempt: abort
806    /// without reporting, before the target is touched.
807    #[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    /// A permanent rotate failure reports the integration's code and the sync state of its effect.
821    #[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    /// Verify runs after the rotate applied, so its failure is `target_updated` whatever the
842    /// effect says.
843    #[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    /// A failed session termination never fails the rotation: the success report carries
864    /// `term_failed` (wire value 2).
865    #[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}