Skip to main content

bitwarden_access_connector/executor/
mod.rs

1//! Rotation job executor: scheduling, retry, and lifecycle management.
2//!
3//! No socket exists, so a network blip does not close the session; only shutdown does.
4
5pub(crate) mod retry;
6pub(crate) mod rotation;
7
8use std::{collections::HashMap, ops::ControlFlow, path::PathBuf, sync::Arc, time::Duration};
9
10use bitwarden_access_token::AccessToken;
11use bitwarden_core::{Client, key_management::KeySlotIds};
12use bitwarden_crypto::KeyStore;
13use bitwarden_pam::{RotationFailureCode, RotationSyncState, WorkSnapshot};
14use bitwarden_pam_sealed::SealedTargetSystemKind;
15use bitwarden_threading::cancellation_token::CancellationToken;
16use retry::RetryCfg;
17use rotation::{AbortReason, ExecutionContext, ExecutionResult, LastOk, execute};
18use tokio::{
19    sync::watch,
20    time::{Instant, MissedTickBehavior, interval},
21};
22use uuid::Uuid;
23
24use crate::{
25    api::{
26        AccessConnectorClient, AccessConnectorClientExt as _, REQUEST_TIMEOUT, build_sdk_client,
27        models::{ApiError, JobRef},
28    },
29    auth::{
30        identity::IdentityClient,
31        session::{SessionError, SessionLost, SessionManager},
32    },
33    error::SafeDetail,
34    integrations::{
35        IntegrationRegistry,
36        active_directory::ActiveDirectoryIntegration,
37        entra::EntraIntegration,
38        scripting::{CustomScriptIntegration, PowershellConfig, ProcessScriptRunner},
39    },
40    resolver::{
41        CredentialResolver,
42        config::{ConfigCredentialResolver, TargetEntry},
43    },
44    sys::{Platform, SystemEnv},
45};
46
47/// Why the run loop exited.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum RunExit {
50    /// Shutdown was requested.
51    Shutdown,
52    /// The credential was rejected or revoked; it must be reissued.
53    CredentialRefused,
54    /// Startup authentication failed, but not because the credential was refused.
55    StartupFailed,
56    /// The connector is not eligible for the rotation endpoints.
57    NotEligible,
58}
59
60/// Configuration for the connector run loop, validated by `Config::from_cli`.
61pub struct AccessConnectorConfig {
62    pub(crate) api_url: String,
63    pub(crate) identity_url: String,
64    pub(crate) token: AccessToken,
65    /// How often the connector polls for new jobs (default: 15 s).
66    pub(crate) poll_interval: Duration,
67    /// How often the heartbeat fires during an executing rotation (default: 30 s).
68    pub(crate) heartbeat_interval: Duration,
69    /// Time without server contact before the gate pauses target-side steps (default: 60 s).
70    pub(crate) offline_grace: Duration,
71    pub(crate) retry_cfg: RetryCfg,
72    pub(crate) script_root: Option<PathBuf>,
73    /// Script execution timeout for the `CustomScript` integration (default: 60 s).
74    pub(crate) script_timeout: Duration,
75    /// Explicit PowerShell host path; `None` discovers one on `PATH`.
76    pub(crate) powershell_path: Option<PathBuf>,
77    /// `-ExecutionPolicy` value passed to the PowerShell host (default: `Bypass`).
78    pub(crate) powershell_execution_policy: String,
79    /// Whether to enable the Entra verify probe (ROPC-based; off by default).
80    pub(crate) entra_verify_probe: bool,
81    /// Per-target credential overrides from the `[targets]` config section.
82    pub(crate) targets: HashMap<Uuid, TargetEntry>,
83}
84
85impl AccessConnectorConfig {
86    /// The token's API key ID, which identifies the access connector without the secret.
87    pub fn api_key_id(&self) -> Uuid {
88        self.token.api_key_id()
89    }
90
91    /// Builds a config for integration tests, bypassing validation.
92    #[doc(hidden)]
93    pub fn new_for_test(
94        api_url: String,
95        identity_url: String,
96        token: AccessToken,
97        poll_interval: Duration,
98        script_root: Option<PathBuf>,
99    ) -> Self {
100        Self {
101            api_url,
102            identity_url,
103            token,
104            poll_interval,
105            heartbeat_interval: Duration::from_millis(500),
106            offline_grace: Duration::from_secs(60),
107            retry_cfg: RetryCfg {
108                max_retry_attempts: 2,
109                retry_base_delay: Duration::from_millis(10),
110            },
111            script_root,
112            // PowerShell can take several seconds to start on a busy CI runner.
113            script_timeout: Duration::from_secs(30),
114            powershell_path: None,
115            powershell_execution_policy: "Bypass".to_string(),
116            entra_verify_probe: false,
117            targets: HashMap::new(),
118        }
119    }
120}
121
122/// Why a tick of the run loop stopped early.
123enum Stop {
124    /// Nothing more to do this tick; wait for the next one.
125    Skip,
126    Exit(RunExit),
127}
128
129/// Poll backoff after a transient error, doubling from the base up to the cap.
130const POLL_BACKOFF_BASE: Duration = Duration::from_secs(1);
131const POLL_BACKOFF_CAP: Duration = Duration::from_secs(60);
132
133/// Authenticated connector state shared by the stages of the run loop.
134struct Worker {
135    ctx: ExecutionContext,
136    api: AccessConnectorClient,
137    key_store: KeyStore<KeySlotIds>,
138    heartbeat_interval: Duration,
139}
140
141/// Runs the connector poll loop. A revoked session exits as [`RunExit::CredentialRefused`], other
142/// startup failures as [`RunExit::StartupFailed`], and a 404 that survives a refresh probe as
143/// [`RunExit::NotEligible`].
144pub(crate) async fn run(cfg: AccessConnectorConfig, cancel: CancellationToken) -> RunExit {
145    let poll_interval = cfg.poll_interval;
146    let worker = match Worker::start(cfg, cancel.clone()).await {
147        Ok(w) => w,
148        Err(exit) => return exit,
149    };
150
151    let mut poll_ticker = interval(poll_interval);
152    poll_ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
153
154    let mut poll_backoff = POLL_BACKOFF_BASE;
155
156    let exit = loop {
157        tokio::select! {
158            _ = cancel.cancelled() => break RunExit::Shutdown,
159            _ = poll_ticker.tick() => {}
160        }
161
162        if let ControlFlow::Break(Stop::Exit(exit)) = worker.tick(&mut poll_backoff).await {
163            break exit;
164        }
165    };
166
167    if exit == RunExit::Shutdown {
168        tracing::info!("shutdown requested; closing session");
169        worker.ctx.session.close().await;
170    }
171    exit
172}
173
174impl Worker {
175    /// Authenticates and assembles the loop state, or returns the exit for a failed startup.
176    async fn start(cfg: AccessConnectorConfig, cancel: CancellationToken) -> Result<Self, RunExit> {
177        let identity_client = IdentityClient::new(cfg.identity_url.clone()).map_err(|e| {
178            tracing::error!("failed to build identity client: {e}");
179            RunExit::StartupFailed
180        })?;
181
182        // URLs only, never the token.
183        tracing::info!(
184            api_url = %cfg.api_url,
185            identity_url = %cfg.identity_url,
186            poll_interval_secs = cfg.poll_interval.as_secs(),
187            heartbeat_interval_secs = cfg.heartbeat_interval.as_secs(),
188            configured_targets = cfg.targets.len(),
189            "access connector starting"
190        );
191
192        // Built first, since the session installs the org key into the client's key store.
193        let (connectivity_tx, connectivity_rx) = watch::channel(Instant::now());
194        let (sdk_client, handler) = build_sdk_client(
195            cfg.api_url.clone(),
196            cfg.identity_url.clone(),
197            REQUEST_TIMEOUT,
198            connectivity_tx,
199        );
200
201        let key_store = handler
202            .key_store()
203            .expect("ClientBuilder::build calls initialize_middleware, which sets the key store");
204
205        let integrations = build_integrations(&cfg);
206
207        // `SessionManager::new` backs off internally; `select!` lets cancellation interrupt it.
208        let session = tokio::select! {
209            result = SessionManager::new(identity_client, cfg.token, key_store.clone()) => {
210                match result {
211                    Ok(s) => s,
212                    Err(SessionError::Lost(SessionLost::Revoked)) => {
213                        tracing::warn!("credential refused at startup");
214                        return Err(RunExit::CredentialRefused);
215                    }
216                    Err(e) => {
217                        // Transient failures outlasted the startup retries.
218                        tracing::error!("startup authentication failed: {e}");
219                        return Err(RunExit::StartupFailed);
220                    }
221                }
222            }
223            _ = cancel.cancelled() => {
224                tracing::info!("shutdown requested during startup; exiting");
225                return Err(RunExit::Shutdown);
226            }
227        };
228
229        handler.set_session(Arc::clone(&session));
230
231        let api = sdk_client.access_connector();
232
233        let resolver: Arc<dyn CredentialResolver> = Arc::new(ConfigCredentialResolver::new(
234            cfg.targets,
235            Arc::new(SystemEnv),
236        ));
237
238        // `SessionManager::new` authenticates eagerly, so the claim is already available.
239        let Some(organization_id) = session.organization_id().await else {
240            tracing::error!("access token carries no usable organization claim");
241            return Err(RunExit::CredentialRefused);
242        };
243
244        let last_ok: LastOk = Arc::new(move || *connectivity_rx.borrow());
245
246        Ok(Self {
247            ctx: ExecutionContext {
248                client: sdk_client,
249                session,
250                integrations,
251                resolver,
252                organization_id,
253                retry_cfg: cfg.retry_cfg,
254                offline_grace: cfg.offline_grace,
255                last_ok,
256                cancel,
257            },
258            api,
259            key_store,
260            heartbeat_interval: cfg.heartbeat_interval,
261        })
262    }
263
264    /// One pass of the loop: poll, claim at most one job, and rotate it.
265    async fn tick(&self, backoff: &mut Duration) -> ControlFlow<Stop> {
266        let jobs = self.poll(backoff).await?;
267        let snap = self.claim_one(jobs).await?;
268        self.rotate(snap).await
269    }
270
271    /// Fetches claimable jobs, backing off on transient errors.
272    async fn poll(&self, backoff: &mut Duration) -> ControlFlow<Stop, Vec<JobRef>> {
273        match self.api.poll_jobs().await {
274            Ok(jobs) => {
275                *backoff = POLL_BACKOFF_BASE;
276                tracing::debug!(claimable_jobs = jobs.len(), "poll tick");
277                ControlFlow::Continue(jobs)
278            }
279            Err(ApiError::SessionLost(SessionLost::Revoked)) => {
280                tracing::warn!("credential refused while polling");
281                ControlFlow::Break(Stop::Exit(RunExit::CredentialRefused))
282            }
283            Err(ApiError::NotEligible) => {
284                // Tell a revoked credential from an organization that lost access.
285                ControlFlow::Break(
286                    self.handle_not_eligible()
287                        .await
288                        .map_or(Stop::Skip, Stop::Exit),
289                )
290            }
291            Err(ApiError::Transient(msg)) => {
292                tracing::warn!("transient poll error: {msg}; backing off {backoff:?}");
293                tokio::select! {
294                    _ = tokio::time::sleep(*backoff) => {}
295                    _ = self.ctx.cancel.cancelled() => {
296                        return ControlFlow::Break(Stop::Exit(RunExit::Shutdown));
297                    }
298                }
299                *backoff = (*backoff * 2).min(POLL_BACKOFF_CAP);
300                ControlFlow::Break(Stop::Skip)
301            }
302            Err(e) => {
303                tracing::warn!("poll error (non-transient): {e}");
304                ControlFlow::Break(Stop::Skip)
305            }
306        }
307    }
308
309    /// Claims the first job that is still available; skips the tick when none is.
310    async fn claim_one(&self, jobs: Vec<JobRef>) -> ControlFlow<Stop, WorkSnapshot> {
311        for job in jobs {
312            match self.api.claim(job.id).await {
313                Ok(Some(s)) => {
314                    tracing::info!(
315                        job_id = %s.job_id,
316                        target_system_name = %s.target_system_name,
317                        "claimed rotation job"
318                    );
319                    return ControlFlow::Continue(s); // at most one claim per tick
320                }
321                Ok(None) => {
322                    tracing::debug!(job_id = %job.id, "claim race lost (409); trying next job");
323                }
324                Err(ApiError::SessionLost(SessionLost::Revoked)) => {
325                    tracing::warn!("credential refused while claiming a job");
326                    return ControlFlow::Break(Stop::Exit(RunExit::CredentialRefused));
327                }
328                Err(ApiError::NotEligible) => {
329                    return ControlFlow::Break(
330                        self.handle_not_eligible()
331                            .await
332                            .map_or(Stop::Skip, Stop::Exit),
333                    );
334                }
335                Err(e) => {
336                    tracing::warn!("claim error: {e}");
337                    return ControlFlow::Break(Stop::Skip);
338                }
339            }
340        }
341        ControlFlow::Break(Stop::Skip)
342    }
343
344    /// Verifies and executes a claimed job; breaks only when the credential is refused.
345    async fn rotate(&self, snap: WorkSnapshot) -> ControlFlow<Stop> {
346        // A rejected job never reaches the resolver, an integration or the cipher route.
347        let attempt_id = snap.attempt_id;
348        let verified = match snap.verify(&self.key_store, self.ctx.organization_id) {
349            Ok(v) => v,
350            Err(rejection) => {
351                tracing::warn!(
352                    attempt_id = %attempt_id,
353                    rejection = rejection.as_str(),
354                    "rotation job rejected: sealed data check failed"
355                );
356                rotation::report_failure_absorb(
357                    &self.api,
358                    attempt_id,
359                    RotationFailureCode::SealedDataRejected,
360                    Some(SafeDetail::from_seal_rejection(rejection.as_str())),
361                    RotationSyncState::TargetUnchanged,
362                )
363                .await;
364                return ControlFlow::Continue(());
365            }
366        };
367
368        // Keeps the connectivity watch fresh during the rotation.
369        let heartbeat_cancel = self.ctx.cancel.child_token();
370        let heartbeat_handle = spawn_heartbeat(
371            self.ctx.client.clone(),
372            self.heartbeat_interval,
373            heartbeat_cancel.clone(),
374        );
375
376        let result = execute(verified, &self.ctx).await;
377
378        heartbeat_cancel.cancel();
379        if let Err(e) = heartbeat_handle.await {
380            tracing::warn!("heartbeat task failed: {e}");
381        }
382
383        match result {
384            ExecutionResult::Reported => {
385                tracing::debug!(attempt_id = %attempt_id, "rotation attempt reported");
386                ControlFlow::Continue(())
387            }
388            ExecutionResult::Unreported(AbortReason::SessionLost(SessionLost::Revoked)) => {
389                tracing::warn!("credential refused during rotation");
390                ControlFlow::Break(Stop::Exit(RunExit::CredentialRefused))
391            }
392            ExecutionResult::Unreported(reason) => {
393                tracing::info!("rotation attempt aborted (unreported): {reason:?}");
394                ControlFlow::Continue(())
395            }
396        }
397    }
398
399    /// Forces a token refresh to tell a revoked credential from a connector that is not eligible.
400    /// `None` means the 404 did not survive the refresh (or a transient error hid it); keep
401    /// polling.
402    async fn handle_not_eligible(&self) -> Option<RunExit> {
403        let session = &self.ctx.session;
404
405        // The current bearer is the stale token to refresh.
406        let stale = match session.bearer(None).await {
407            Ok(t) => t,
408            Err(e) => return refused(&e),
409        };
410        if let Err(e) = session.force_refresh(&stale, None).await {
411            return refused(&e);
412        }
413
414        match self.api.poll_jobs().await {
415            Err(ApiError::NotEligible) => Some(RunExit::NotEligible),
416            Err(ApiError::SessionLost(SessionLost::Revoked)) => Some(RunExit::CredentialRefused),
417            _ => None,
418        }
419    }
420}
421
422/// Registers the integrations the connector can drive.
423fn build_integrations(cfg: &AccessConnectorConfig) -> Arc<IntegrationRegistry> {
424    let mut registry = IntegrationRegistry::new();
425
426    let custom_script = Arc::new(CustomScriptIntegration {
427        script_root: cfg.script_root.clone(),
428        timeout: cfg.script_timeout,
429        powershell: PowershellConfig {
430            host: cfg.powershell_path.clone(),
431            execution_policy: cfg.powershell_execution_policy.clone(),
432        },
433        platform: Platform::system(),
434        runner: Arc::new(ProcessScriptRunner),
435    });
436    registry.register(SealedTargetSystemKind::CustomScript, custom_script);
437
438    let entra = Arc::new(EntraIntegration::new(cfg.entra_verify_probe));
439    registry.register(SealedTargetSystemKind::Entra, entra);
440
441    let active_directory = Arc::new(ActiveDirectoryIntegration::new());
442    registry.register(SealedTargetSystemKind::ActiveDirectory, active_directory);
443
444    Arc::new(registry)
445}
446
447/// A revoked session ends the run; any other session error is transient, so keep polling.
448fn refused(e: &SessionError) -> Option<RunExit> {
449    matches!(e, SessionError::Lost(SessionLost::Revoked)).then_some(RunExit::CredentialRefused)
450}
451
452/// Polls the jobs endpoint for the connectivity bump until `cancel` fires.
453fn spawn_heartbeat(
454    client: Client,
455    interval_period: Duration,
456    cancel: CancellationToken,
457) -> tokio::task::JoinHandle<()> {
458    tokio::spawn(async move {
459        let api = client.access_connector();
460        let mut ticker = interval(interval_period);
461        ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
462        loop {
463            tokio::select! {
464                _ = cancel.cancelled() => break,
465                _ = ticker.tick() => {
466                    tracing::debug!("heartbeat tick");
467                    let _ = api.poll_jobs().await;
468                }
469            }
470        }
471    })
472}
473
474#[cfg(test)]
475mod tests {
476    use wiremock::{
477        Mock, MockServer, ResponseTemplate,
478        matchers::{method, path},
479    };
480
481    use super::*;
482    use crate::test_support::{make_session, mount_identity_ok, test_token};
483
484    const JOBS_PATH: &str = "/access-connectors/rotation/jobs";
485
486    fn make_client(
487        api_server: &MockServer,
488        session: Arc<SessionManager>,
489    ) -> (Client, watch::Receiver<Instant>) {
490        let (tx, rx) = watch::channel(Instant::now());
491        let (client, handler) = build_sdk_client(
492            api_server.uri(),
493            "http://identity.invalid".to_owned(),
494            REQUEST_TIMEOUT,
495            tx,
496        );
497        handler.set_session(session);
498        (client, rx)
499    }
500
501    /// Mounts a `/rotation/jobs` mock answering `response`, for at most `times` requests when
502    /// given.
503    async fn mount_jobs(server: &MockServer, response: ResponseTemplate, times: Option<u64>) {
504        let mock = Mock::given(method("GET"))
505            .and(path(JOBS_PATH))
506            .respond_with(response);
507        match times {
508            Some(n) => mock.up_to_n_times(n).mount(server).await,
509            None => mock.mount(server).await,
510        }
511    }
512
513    /// Mounts a claim mock for `job_id` answering `response`.
514    async fn mount_claim(server: &MockServer, job_id: Uuid, response: ResponseTemplate) {
515        Mock::given(method("POST"))
516            .and(path(format!("{JOBS_PATH}/{job_id}/claim")))
517            .respond_with(response)
518            .mount(server)
519            .await;
520    }
521
522    /// Mounts a failure-report mock for `attempt_id` that accepts the report.
523    async fn mount_failure(server: &MockServer, attempt_id: Uuid) {
524        Mock::given(method("POST"))
525            .and(path(format!(
526                "/access-connectors/rotation/attempts/{attempt_id}/failure"
527            )))
528            .respond_with(ResponseTemplate::new(200))
529            .mount(server)
530            .await;
531    }
532
533    /// A jobs list offering each `(job id, target system id)` pair.
534    fn jobs(refs: &[(Uuid, Uuid)]) -> ResponseTemplate {
535        let data: Vec<_> = refs
536            .iter()
537            .map(|(job_id, target_system_id)| {
538                serde_json::json!({"jobId": job_id, "targetSystemId": target_system_id})
539            })
540            .collect();
541        ResponseTemplate::new(200)
542            .set_body_json(serde_json::json!({ "data": data }))
543            .insert_header("content-type", "application/json")
544    }
545
546    fn empty_jobs() -> ResponseTemplate {
547        jobs(&[])
548    }
549
550    /// A successful claim of `job_id` whose sealed blobs are opaque, so verification rejects them.
551    fn claim_ok(job_id: Uuid, attempt_id: Uuid) -> ResponseTemplate {
552        let execute_by = chrono::Utc::now()
553            .checked_add_signed(chrono::Duration::minutes(5))
554            .unwrap()
555            .to_rfc3339();
556        ResponseTemplate::new(200)
557            .set_body_json(serde_json::json!({
558                "attemptId": attempt_id,
559                "jobId": job_id,
560                "targetSystemId": Uuid::new_v4(),
561                "targetSystemName": "test",
562                "cipherId": Uuid::new_v4(),
563                "rotationConfigSealedData": "opaque-config-blob",
564                "rotationConfigWrappedKey": "opaque-config-key",
565                "executeBy": execute_by
566            }))
567            .insert_header("content-type", "application/json")
568    }
569
570    fn test_cfg(api_server: &MockServer, identity_server: &MockServer) -> AccessConnectorConfig {
571        AccessConnectorConfig::new_for_test(
572            api_server.uri(),
573            identity_server.uri(),
574            test_token(),
575            Duration::from_millis(30),
576            None,
577        )
578    }
579
580    /// Spawns `run` and returns its cancellation token and handle.
581    fn spawn_run(
582        cfg: AccessConnectorConfig,
583    ) -> (CancellationToken, tokio::task::JoinHandle<RunExit>) {
584        let cancel = CancellationToken::new();
585        let cancel_clone = cancel.clone();
586        let handle = tokio::spawn(async move { run(cfg, cancel_clone).await });
587        (cancel, handle)
588    }
589
590    /// Runs `cfg` until the loop exits on its own, failing if it takes over 10s.
591    async fn run_until_exit(cfg: AccessConnectorConfig) -> RunExit {
592        tokio::time::timeout(Duration::from_secs(10), run(cfg, CancellationToken::new()))
593            .await
594            .expect("run should exit on its own")
595    }
596
597    /// Number of requests `server` received for `method_name` on a path ending with `suffix`.
598    async fn count_requests(server: &MockServer, method_name: &str, suffix: &str) -> usize {
599        server
600            .received_requests()
601            .await
602            .unwrap()
603            .iter()
604            .filter(|r| r.method.as_str() == method_name && r.url.path().ends_with(suffix))
605            .count()
606    }
607
608    /// Polls until `server` has received `n` requests for `method_name` on a path ending with
609    /// `suffix`.
610    async fn wait_for_requests(server: &MockServer, method_name: &str, suffix: &str, n: usize) {
611        let deadline = Instant::now() + Duration::from_secs(10);
612        loop {
613            let count = count_requests(server, method_name, suffix).await;
614            if count >= n {
615                return;
616            }
617            assert!(
618                Instant::now() < deadline,
619                "expected {n} {method_name} requests ending with {suffix} within 10s, got {count}"
620            );
621            tokio::time::sleep(Duration::from_millis(10)).await;
622        }
623    }
624
625    #[tokio::test]
626    async fn single_flight_only_one_claim_per_tick() {
627        let identity_server = MockServer::start().await;
628        let api_server = MockServer::start().await;
629        mount_identity_ok(&identity_server, None).await;
630
631        let job1 = Uuid::new_v4();
632        let job2 = Uuid::new_v4();
633        let job3 = Uuid::new_v4();
634        let attempt_id = Uuid::new_v4();
635
636        // Offer all three jobs once so the claim cycle doesn't repeat.
637        mount_jobs(
638            &api_server,
639            jobs(&[
640                (job1, Uuid::new_v4()),
641                (job2, Uuid::new_v4()),
642                (job3, Uuid::new_v4()),
643            ]),
644            Some(1),
645        )
646        .await;
647        mount_jobs(&api_server, empty_jobs(), None).await;
648
649        // job1 loses its race, so the loop moves on to job2.
650        mount_claim(&api_server, job1, ResponseTemplate::new(409)).await;
651
652        // job2's claim succeeds, but its sealed blobs are rejected, so nothing executes.
653        mount_claim(&api_server, job2, claim_ok(job2, attempt_id)).await;
654
655        // job3 would also succeed, so claiming it would show the loop did not stop.
656        mount_claim(&api_server, job3, claim_ok(job3, Uuid::new_v4())).await;
657
658        mount_failure(&api_server, attempt_id).await;
659
660        let cfg = test_cfg(&api_server, &identity_server);
661        let (cancel, handle) = spawn_run(cfg);
662
663        wait_for_requests(
664            &api_server,
665            "POST",
666            &format!("/attempts/{attempt_id}/failure"),
667            1,
668        )
669        .await;
670        cancel.cancel();
671        let exit = handle.await.expect("task panicked");
672        assert_eq!(exit, RunExit::Shutdown);
673
674        let claim_suffix = |job: Uuid| format!("/jobs/{job}/claim");
675        assert!(
676            count_requests(&api_server, "POST", &claim_suffix(job1)).await >= 1,
677            "job1 should have been claimed (and lost)"
678        );
679        assert!(
680            count_requests(&api_server, "POST", &claim_suffix(job2)).await >= 1,
681            "job2 should have been claimed"
682        );
683        assert_eq!(
684            count_requests(&api_server, "POST", &claim_suffix(job3)).await,
685            0,
686            "the loop must stop after the first successful claim in a tick"
687        );
688    }
689
690    #[tokio::test]
691    async fn heartbeat_polls_jobs_until_cancelled() {
692        let identity_server = MockServer::start().await;
693        let api_server = MockServer::start().await;
694
695        let session = make_session(&identity_server).await;
696        let (client, _rx) = make_client(&api_server, Arc::clone(&session));
697
698        mount_jobs(&api_server, empty_jobs(), None).await;
699
700        let heartbeat_interval = Duration::from_millis(20);
701        let cancel = CancellationToken::new();
702        let handle = spawn_heartbeat(client, heartbeat_interval, cancel.clone());
703
704        wait_for_requests(&api_server, "GET", JOBS_PATH, 2).await;
705
706        cancel.cancel();
707        handle.await.expect("heartbeat task panicked");
708        let count_after_stop = count_requests(&api_server, "GET", JOBS_PATH).await;
709
710        // Wait several intervals to confirm no new calls.
711        tokio::time::sleep(heartbeat_interval * 5).await;
712
713        let count_final = count_requests(&api_server, "GET", JOBS_PATH).await;
714        assert_eq!(
715            count_after_stop, count_final,
716            "no heartbeat calls after cancellation"
717        );
718        assert!(
719            count_after_stop >= 2,
720            "heartbeat should have fired at least twice"
721        );
722    }
723
724    #[tokio::test]
725    async fn poll_404_surviving_refresh_exits_not_eligible() {
726        let identity_server = MockServer::start().await;
727        let api_server = MockServer::start().await;
728        mount_identity_ok(&identity_server, None).await;
729        mount_jobs(&api_server, ResponseTemplate::new(404), None).await;
730
731        let exit = run_until_exit(test_cfg(&api_server, &identity_server)).await;
732        assert_eq!(exit, RunExit::NotEligible);
733
734        let token_requests = count_requests(&identity_server, "POST", "/connect/token").await;
735        assert!(
736            token_requests >= 2,
737            "expected startup plus a forced refresh, got {token_requests} token requests"
738        );
739    }
740
741    #[tokio::test]
742    async fn poll_404_cleared_by_refresh_keeps_polling() {
743        let identity_server = MockServer::start().await;
744        let api_server = MockServer::start().await;
745        mount_identity_ok(&identity_server, None).await;
746        mount_jobs(&api_server, ResponseTemplate::new(404), Some(1)).await;
747        mount_jobs(&api_server, empty_jobs(), None).await;
748
749        let (cancel, handle) = spawn_run(test_cfg(&api_server, &identity_server));
750
751        // One 404, the re-poll after the refresh, then at least one more regular tick.
752        wait_for_requests(&api_server, "GET", JOBS_PATH, 3).await;
753        assert!(!handle.is_finished(), "a cleared 404 must not end the loop");
754
755        cancel.cancel();
756        let exit = tokio::time::timeout(Duration::from_secs(10), handle)
757            .await
758            .expect("run should stop on cancel")
759            .expect("task panicked");
760        assert_eq!(exit, RunExit::Shutdown);
761    }
762
763    #[tokio::test]
764    async fn poll_404_with_refused_refresh_exits_credential_refused() {
765        let identity_server = MockServer::start().await;
766        let api_server = MockServer::start().await;
767        mount_identity_ok(&identity_server, Some(1)).await;
768        Mock::given(method("POST"))
769            .and(path("/connect/token"))
770            .respond_with(
771                ResponseTemplate::new(400)
772                    .set_body_string(r#"{"error":"invalid_grant"}"#)
773                    .insert_header("content-type", "application/json"),
774            )
775            .mount(&identity_server)
776            .await;
777        mount_jobs(&api_server, ResponseTemplate::new(404), None).await;
778
779        let exit = run_until_exit(test_cfg(&api_server, &identity_server)).await;
780        assert_eq!(exit, RunExit::CredentialRefused);
781    }
782
783    #[tokio::test]
784    async fn claim_with_missing_target_blob_reports_sealed_data_rejected_and_never_executes() {
785        let identity_server = MockServer::start().await;
786        let api_server = MockServer::start().await;
787        mount_identity_ok(&identity_server, None).await;
788
789        let job_id = Uuid::new_v4();
790        let attempt_id = Uuid::new_v4();
791
792        // Offer the job once so the claim/reject cycle doesn't repeat.
793        mount_jobs(&api_server, jobs(&[(job_id, Uuid::new_v4())]), Some(1)).await;
794        mount_jobs(&api_server, empty_jobs(), None).await;
795
796        mount_claim(&api_server, job_id, claim_ok(job_id, attempt_id)).await;
797
798        mount_failure(&api_server, attempt_id).await;
799
800        let cfg = test_cfg(&api_server, &identity_server);
801        let (cancel, handle) = spawn_run(cfg);
802
803        wait_for_requests(&api_server, "POST", "/failure", 1).await;
804        cancel.cancel();
805        let exit = handle.await.expect("task panicked");
806        assert_eq!(exit, RunExit::Shutdown);
807
808        let all_reqs = api_server.received_requests().await.unwrap();
809
810        let cipher_reqs: Vec<_> = all_reqs
811            .iter()
812            .filter(|r| r.url.path().contains("/cipher"))
813            .collect();
814        assert!(
815            cipher_reqs.is_empty(),
816            "no cipher call should happen for a rejected claim: {cipher_reqs:?}"
817        );
818
819        let failure_reqs: Vec<_> = all_reqs
820            .iter()
821            .filter(|r| r.url.path().ends_with("/failure"))
822            .collect();
823        assert_eq!(failure_reqs.len(), 1, "expected exactly one failure report");
824        let body: serde_json::Value =
825            serde_json::from_slice(&failure_reqs[0].body).expect("parse failure body");
826        assert_eq!(
827            body["errorCode"],
828            serde_json::json!("sealed_data_rejected"),
829            "errorCode must be sealed_data_rejected: {body}"
830        );
831        assert_eq!(
832            body["syncState"],
833            serde_json::json!(0),
834            "syncState must be 0 (TargetUnchanged): {body}"
835        );
836    }
837}