1pub(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#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum RunExit {
50 Shutdown,
52 CredentialRefused,
54 StartupFailed,
56 NotEligible,
58}
59
60pub struct AccessConnectorConfig {
62 pub(crate) api_url: String,
63 pub(crate) identity_url: String,
64 pub(crate) token: AccessToken,
65 pub(crate) poll_interval: Duration,
67 pub(crate) heartbeat_interval: Duration,
69 pub(crate) offline_grace: Duration,
71 pub(crate) retry_cfg: RetryCfg,
72 pub(crate) script_root: Option<PathBuf>,
73 pub(crate) script_timeout: Duration,
75 pub(crate) powershell_path: Option<PathBuf>,
77 pub(crate) powershell_execution_policy: String,
79 pub(crate) entra_verify_probe: bool,
81 pub(crate) targets: HashMap<Uuid, TargetEntry>,
83}
84
85impl AccessConnectorConfig {
86 pub fn api_key_id(&self) -> Uuid {
88 self.token.api_key_id()
89 }
90
91 #[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 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
122enum Stop {
124 Skip,
126 Exit(RunExit),
127}
128
129const POLL_BACKOFF_BASE: Duration = Duration::from_secs(1);
131const POLL_BACKOFF_CAP: Duration = Duration::from_secs(60);
132
133struct Worker {
135 ctx: ExecutionContext,
136 api: AccessConnectorClient,
137 key_store: KeyStore<KeySlotIds>,
138 heartbeat_interval: Duration,
139}
140
141pub(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 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 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 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 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 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 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 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 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 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 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); }
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 async fn rotate(&self, snap: WorkSnapshot) -> ControlFlow<Stop> {
346 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 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 async fn handle_not_eligible(&self) -> Option<RunExit> {
403 let session = &self.ctx.session;
404
405 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
422fn 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
447fn refused(e: &SessionError) -> Option<RunExit> {
449 matches!(e, SessionError::Lost(SessionLost::Revoked)).then_some(RunExit::CredentialRefused)
450}
451
452fn 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 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 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 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 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 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 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 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 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 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 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 mount_claim(&api_server, job1, ResponseTemplate::new(409)).await;
651
652 mount_claim(&api_server, job2, claim_ok(job2, attempt_id)).await;
654
655 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 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 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 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}