Skip to main content

bitwarden_pam/rotation/
configs.rs

1use std::sync::Arc;
2
3use bitwarden_api_api::models::{
4    CreateRotationConfigRequestModel, PamRotationConfigDetailResponseModel,
5    PamRotationConfigResponseModel, PamTargetSystemResponseModel, UpdateRotationConfigRequestModel,
6};
7use bitwarden_core::{
8    FromClient, OrganizationId,
9    client::ApiConfigurations,
10    key_management::{KeySlotIds, SymmetricKeySlotId},
11    require,
12};
13use bitwarden_crypto::{
14    CryptoError, Decryptable, EncString, IdentifyKey, KeyStore, KeyStoreContext, safe::DataEnvelope,
15};
16use bitwarden_pam_sealed::{
17    RotationConfigSealedV1, Sealed, seal_rotation_config, unseal_rotation_config,
18};
19use bitwarden_vault::CipherId;
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22use uuid::Uuid;
23
24use super::{
25    actions::{RotationConfigActions, rotation_config_actions},
26    error::RotationError,
27    models::{RotationJob, TargetSystemMethod, TargetSystemStatus},
28    target_systems::{TargetSystem, TargetSystemView},
29    validate::{RotationValidationError, validate_config_request},
30};
31use crate::{RotationConfigId, TargetSystemId};
32
33/// A rotation config as the server returns it, with its security-relevant fields still sealed.
34#[derive(Debug, Clone, PartialEq)]
35pub(crate) struct RotationConfig {
36    id: RotationConfigId,
37    organization_id: OrganizationId,
38    cipher_id: CipherId,
39    target_system_id: TargetSystemId,
40    target_system_name: String,
41    target_system_method: TargetSystemMethod,
42    sealed_data: Option<String>,
43    wrapped_key: Option<String>,
44    schedule_cron: Option<String>,
45    rotate_on_access_end: bool,
46    enabled: bool,
47    last_rotation_at: Option<DateTime<Utc>>,
48    next_rotation_at: Option<DateTime<Utc>>,
49    has_active_job: bool,
50    awaiting_manual_rotation: bool,
51    creation_date: DateTime<Utc>,
52    revision_date: DateTime<Utc>,
53}
54
55impl RotationConfig {
56    /// `organization_id` must be the queried id, not `response.organization_id`, or a hostile
57    /// server could pass off another organization's validly-sealed blob.
58    fn from_response(
59        organization_id: OrganizationId,
60        response: PamRotationConfigResponseModel,
61    ) -> Result<Self, RotationError> {
62        let (sealed_data, wrapped_key) = if response.organization_id == Some(organization_id.into())
63        {
64            (response.sealed_data, response.wrapped_key)
65        } else {
66            tracing::debug!("PAM rotation config organization_id mismatch");
67            (None, None)
68        };
69
70        Ok(Self {
71            id: RotationConfigId::new(require!(response.id)),
72            organization_id,
73            cipher_id: CipherId::new(require!(response.cipher_id)),
74            target_system_id: TargetSystemId::new(require!(response.target_system_id)),
75            target_system_name: response.target_system_name.unwrap_or_default(),
76            target_system_method: require!(response.target_system_method).into(),
77            sealed_data,
78            wrapped_key,
79            schedule_cron: response.schedule_cron,
80            rotate_on_access_end: response.rotate_on_access_end.unwrap_or(false),
81            enabled: response.enabled.unwrap_or(true),
82            last_rotation_at: response
83                .last_rotation_at
84                .map(|date| date.parse())
85                .transpose()?,
86            next_rotation_at: response
87                .next_rotation_at
88                .map(|date| date.parse())
89                .transpose()?,
90            has_active_job: response.has_active_job.unwrap_or(false),
91            awaiting_manual_rotation: response.awaiting_manual_rotation.unwrap_or(false),
92            creation_date: require!(response.creation_date).parse()?,
93            revision_date: require!(response.revision_date).parse()?,
94        })
95    }
96
97    /// Unseals the blob and checks it against this row's `target_system_id` and `cipher_id`. Any
98    /// problem degrades to `None` rather than failing, so one bad blob can't fail a `list`.
99    fn unseal(
100        &self,
101        key: SymmetricKeySlotId,
102        ctx: &mut KeyStoreContext<KeySlotIds>,
103    ) -> Option<RotationConfigSealedFields> {
104        self.try_unseal(key, ctx)
105            .inspect_err(|reason| {
106                tracing::debug!(
107                    config_id = %self.id,
108                    reason,
109                    "PAM rotation config sealed blob rejected"
110                )
111            })
112            .ok()
113    }
114
115    fn try_unseal(
116        &self,
117        key: SymmetricKeySlotId,
118        ctx: &mut KeyStoreContext<KeySlotIds>,
119    ) -> Result<RotationConfigSealedFields, &'static str> {
120        let sealed_data = self.sealed_data.as_deref().ok_or("missing blob")?;
121        let wrapped_key = self.wrapped_key.as_deref().ok_or("missing wrapped key")?;
122        let envelope: DataEnvelope = sealed_data.parse().map_err(|_| "malformed sealed data")?;
123        // Unknown formats parse as `Unparseable` and fail to unseal.
124        let wrapped_key = EncString::from(wrapped_key);
125
126        let unsealed = unseal_rotation_config::<KeySlotIds>(&envelope, &wrapped_key, &key, ctx)
127            .map_err(|_| "unseal failed")?;
128
129        if unsealed.target_system_id != Uuid::from(self.target_system_id) {
130            return Err("target_system_id mismatch");
131        }
132        if unsealed.cipher_id != Uuid::from(self.cipher_id) {
133            return Err("cipher_id mismatch");
134        }
135
136        Ok(RotationConfigSealedFields {
137            account_identity: unsealed.account_identity,
138            terminate_sessions: unsealed.terminate_sessions,
139        })
140    }
141}
142
143impl IdentifyKey<SymmetricKeySlotId> for RotationConfig {
144    fn key_identifier(&self) -> SymmetricKeySlotId {
145        SymmetricKeySlotId::Organization(self.organization_id)
146    }
147}
148
149impl Decryptable<KeySlotIds, SymmetricKeySlotId, RotationConfigView> for RotationConfig {
150    /// Never fails: a blob that can't be verified leaves [`RotationConfigView::sealed`] `None`.
151    fn decrypt(
152        &self,
153        ctx: &mut KeyStoreContext<KeySlotIds>,
154        key: SymmetricKeySlotId,
155    ) -> Result<RotationConfigView, CryptoError> {
156        Ok(RotationConfigView {
157            id: self.id,
158            organization_id: self.organization_id,
159            cipher_id: self.cipher_id,
160            target_system_id: self.target_system_id,
161            target_system_name: self.target_system_name.clone(),
162            target_system_method: self.target_system_method,
163            sealed: self.unseal(key, ctx),
164            schedule_cron: self.schedule_cron.clone(),
165            rotate_on_access_end: self.rotate_on_access_end,
166            enabled: self.enabled,
167            last_rotation_at: self.last_rotation_at,
168            next_rotation_at: self.next_rotation_at,
169            has_active_job: self.has_active_job,
170            awaiting_manual_rotation: self.awaiting_manual_rotation,
171            creation_date: self.creation_date,
172            revision_date: self.revision_date,
173        })
174    }
175}
176
177/// A managed credential: the link between a vault cipher and the target system its credential is
178/// rotated against, with its sealed fields unsealed.
179#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
180#[bitwarden_ffi::wasm_record]
181#[serde(rename_all = "camelCase")]
182pub struct RotationConfigView {
183    /// The config's unique identifier.
184    pub id: RotationConfigId,
185    /// The organization this config belongs to.
186    pub organization_id: OrganizationId,
187    /// The vault cipher whose credential this config manages. Its name is vault data, so decrypt
188    /// it from the caller's own cipher state.
189    pub cipher_id: CipherId,
190    /// The target system credentials are rotated against.
191    pub target_system_id: TargetSystemId,
192    /// The target system's display name, as of this read.
193    pub target_system_name: String,
194    /// The target system's rotation method, which decides the actions the config offers.
195    pub target_system_method: TargetSystemMethod,
196    /// `None` if the blob is missing, fails to unseal, or disagrees with this row's IDs.
197    pub sealed: Option<RotationConfigSealedFields>,
198    /// The Quartz cron expression driving scheduled rotation, or `None` for no schedule.
199    pub schedule_cron: Option<String>,
200    /// Whether the credential is rotated at the end of an access lease over the cipher.
201    pub rotate_on_access_end: bool,
202    /// False stops new rotation jobs from dispatching; jobs in flight run to completion.
203    pub enabled: bool,
204    /// The most recent completed rotation (UTC), or `None` absent any rotation.
205    pub last_rotation_at: Option<DateTime<Utc>>,
206    /// When the config is next due for rotation (UTC), or `None` when nothing is due.
207    pub next_rotation_at: Option<DateTime<Utc>>,
208    /// Whether the config has a pending or claimed job, or a timed-out one the sweep has not yet
209    /// recorded.
210    pub has_active_job: bool,
211    /// Whether a manual-method config is waiting for an operator to record an out-of-band
212    /// rotation.
213    pub awaiting_manual_rotation: bool,
214    /// The config's creation time (UTC).
215    pub creation_date: DateTime<Utc>,
216    /// The config's last-modified time (UTC).
217    pub revision_date: DateTime<Utc>,
218}
219
220/// Security-relevant fields of a rotation config; the server only stores them sealed.
221#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
222#[bitwarden_ffi::wasm_record]
223#[serde(rename_all = "camelCase")]
224pub struct RotationConfigSealedFields {
225    /// The account within the target system, e.g. a username or UPN.
226    pub account_identity: String,
227    /// Whether to terminate the account's sessions after a successful rotation.
228    pub terminate_sessions: bool,
229}
230
231impl RotationConfigView {
232    /// Which actions this config offers, given the status of its target system.
233    ///
234    /// See [`rotation_config_actions`] for how an unloaded target system's `target_status` is
235    /// treated; `PendingSeal` counts as any other non-`Active` status.
236    pub fn actions(&self, target_status: Option<TargetSystemStatus>) -> RotationConfigActions {
237        rotation_config_actions(self, target_status)
238    }
239}
240
241/// A config together with its rotation history, still sealed.
242#[derive(Debug, Clone, PartialEq)]
243pub(crate) struct RotationConfigDetail {
244    config: RotationConfig,
245    jobs: Vec<RotationJob>,
246}
247
248impl RotationConfigDetail {
249    fn from_response(
250        organization_id: OrganizationId,
251        response: PamRotationConfigDetailResponseModel,
252    ) -> Result<Self, RotationError> {
253        let jobs = response
254            .jobs
255            .clone()
256            .unwrap_or_default()
257            .into_iter()
258            .map(RotationJob::try_from)
259            .collect::<Result<Vec<_>, _>>()?;
260
261        // The server flattens the config's own fields onto the detail payload, so the list model is
262        // rebuilt from it.
263        let config = RotationConfig::from_response(
264            organization_id,
265            PamRotationConfigResponseModel {
266                object: response.object,
267                id: response.id,
268                organization_id: response.organization_id,
269                cipher_id: response.cipher_id,
270                target_system_id: response.target_system_id,
271                target_system_name: response.target_system_name,
272                target_system_method: response.target_system_method,
273                sealed_data: response.sealed_data,
274                wrapped_key: response.wrapped_key,
275                schedule_cron: response.schedule_cron,
276                rotate_on_access_end: response.rotate_on_access_end,
277                next_rotation_at: response.next_rotation_at,
278                enabled: response.enabled,
279                last_rotation_at: response.last_rotation_at,
280                has_active_job: response.has_active_job,
281                awaiting_manual_rotation: response.awaiting_manual_rotation,
282                creation_date: response.creation_date,
283                revision_date: response.revision_date,
284            },
285        )?;
286
287        Ok(Self { config, jobs })
288    }
289}
290
291impl IdentifyKey<SymmetricKeySlotId> for RotationConfigDetail {
292    fn key_identifier(&self) -> SymmetricKeySlotId {
293        self.config.key_identifier()
294    }
295}
296
297impl Decryptable<KeySlotIds, SymmetricKeySlotId, RotationConfigDetailView>
298    for RotationConfigDetail
299{
300    fn decrypt(
301        &self,
302        ctx: &mut KeyStoreContext<KeySlotIds>,
303        key: SymmetricKeySlotId,
304    ) -> Result<RotationConfigDetailView, CryptoError> {
305        Ok(RotationConfigDetailView {
306            config: self.config.decrypt(ctx, key)?,
307            jobs: self.jobs.clone(),
308        })
309    }
310}
311
312/// A config together with its rotation history.
313#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
314#[bitwarden_ffi::wasm_record]
315#[serde(rename_all = "camelCase")]
316pub struct RotationConfigDetailView {
317    /// The config itself.
318    pub config: RotationConfigView,
319    /// The config's rotation jobs, newest first, each carrying its attempts oldest first.
320    pub jobs: Vec<RotationJob>,
321}
322
323/// Request to create or update a rotation config.
324#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
325#[bitwarden_ffi::wasm_record]
326#[serde(rename_all = "camelCase")]
327pub struct RotationConfigCreateRequest {
328    /// The vault cipher whose credential this config will manage.
329    pub cipher_id: CipherId,
330    /// The target system to rotate against.
331    pub target_system_id: TargetSystemId,
332    /// The account within the target system.
333    pub account_identity: String,
334    /// Whether to terminate the account's sessions after a successful rotation. Requires the
335    /// target's sealed `supports_session_termination` to be `true`.
336    pub terminate_sessions: bool,
337    /// A Quartz cron expression, or `None` for no scheduled rotation.
338    pub schedule_cron: Option<String>,
339    /// Whether to rotate at the end of an access lease over the cipher.
340    pub rotate_on_access_end: bool,
341}
342
343/// A [`RotationConfigCreateRequest`] that passed the local checks. Exists so sealing takes only a
344/// validated request, and those checks can't be skipped.
345struct ValidatedRotationConfig(RotationConfigCreateRequest);
346
347impl ValidatedRotationConfig {
348    fn request(&self) -> &RotationConfigCreateRequest {
349        &self.0
350    }
351
352    /// Seals under `key`. Refuses `terminate_sessions` unless `target` supports it, and binds the
353    /// payload to the target and cipher ids.
354    fn seal(
355        &self,
356        target: &TargetSystemView,
357        key: SymmetricKeySlotId,
358        ctx: &mut KeyStoreContext<KeySlotIds>,
359    ) -> Result<Sealed, RotationError> {
360        let request = self.request();
361        // The target is fetched by the config's `target_system_id`, so a mismatch is a bug.
362        debug_assert_eq!(target.id, request.target_system_id);
363
364        let supports_termination = target
365            .sealed
366            .as_ref()
367            .and_then(|sealed| sealed.supports_session_termination);
368        if request.terminate_sessions && supports_termination != Some(true) {
369            return Err(RotationValidationError::SessionTerminationUnsupported.into());
370        }
371
372        if !ctx.has_symmetric_key(key) {
373            return Err(RotationError::MissingOrganizationKey);
374        }
375
376        let data = RotationConfigSealedV1 {
377            target_system_id: request.target_system_id.into(),
378            cipher_id: request.cipher_id.into(),
379            account_identity: request.account_identity.clone(),
380            terminate_sessions: request.terminate_sessions,
381        };
382        Ok(seal_rotation_config::<KeySlotIds>(data, &key, ctx)?)
383    }
384}
385
386impl TryFrom<RotationConfigCreateRequest> for ValidatedRotationConfig {
387    type Error = RotationError;
388
389    fn try_from(request: RotationConfigCreateRequest) -> Result<Self, Self::Error> {
390        validate_config_request(&request)?;
391        Ok(Self(request))
392    }
393}
394
395/// Client for PAM managed-credential (rotation config) operations.
396#[bitwarden_ffi::wasm_object]
397#[derive(FromClient)]
398pub struct RotationConfigsClient {
399    pub(crate) key_store: KeyStore<KeySlotIds>,
400    pub(crate) api_configurations: Arc<ApiConfigurations>,
401}
402
403#[bitwarden_ffi::wasm_export]
404impl RotationConfigsClient {
405    /// Lists the organization's rotation configs.
406    pub async fn list(
407        &self,
408        organization_id: OrganizationId,
409    ) -> Result<Vec<RotationConfigView>, RotationError> {
410        let response = self
411            .api_configurations
412            .api_client
413            .pam_access_connector_rotation_configs_api()
414            .get_all(organization_id.into())
415            .await?;
416
417        let configs = response
418            .data
419            .unwrap_or_default()
420            .into_iter()
421            .map(|item| RotationConfig::from_response(organization_id, item))
422            .collect::<Result<Vec<_>, _>>()?;
423        Ok(self.key_store.decrypt_list(&configs)?)
424    }
425
426    /// Reads one config with its rotation history.
427    pub async fn get(
428        &self,
429        organization_id: OrganizationId,
430        id: RotationConfigId,
431    ) -> Result<RotationConfigDetailView, RotationError> {
432        let response = self
433            .api_configurations
434            .api_client
435            .pam_access_connector_rotation_configs_api()
436            .get(organization_id.into(), id.into())
437            .await?;
438
439        let detail = RotationConfigDetail::from_response(organization_id, response)?;
440        Ok(self.key_store.decrypt(&detail)?)
441    }
442
443    /// Validates, seals, and creates a rotation config.
444    pub async fn create(
445        &self,
446        organization_id: OrganizationId,
447        request: RotationConfigCreateRequest,
448    ) -> Result<RotationConfigDetailView, RotationError> {
449        let config = ValidatedRotationConfig::try_from(request)?;
450        let target = self
451            .verified_target(organization_id, config.request().target_system_id)
452            .await?;
453
454        let sealed = {
455            let mut ctx = self.key_store.context_mut();
456            config.seal(
457                &target,
458                SymmetricKeySlotId::Organization(organization_id),
459                &mut ctx,
460            )?
461        };
462        let request = config.request();
463        let response = self
464            .api_configurations
465            .api_client
466            .pam_access_connector_rotation_configs_api()
467            .post(
468                organization_id.into(),
469                CreateRotationConfigRequestModel {
470                    cipher_id: request.cipher_id.into(),
471                    target_system_id: request.target_system_id.into(),
472                    sealed_data: sealed.envelope.to_string(),
473                    wrapped_key: sealed.wrapped_key.to_string(),
474                    schedule_cron: request.schedule_cron.clone(),
475                    rotate_on_access_end: Some(request.rotate_on_access_end),
476                },
477            )
478            .await?;
479
480        let detail = RotationConfigDetail::from_response(organization_id, response)?;
481        Ok(self.key_store.decrypt(&detail)?)
482    }
483
484    /// Re-seals the whole blob, so every field must be resupplied; `cipher_id` and
485    /// `target_system_id` must match the existing config.
486    pub async fn update(
487        &self,
488        organization_id: OrganizationId,
489        id: RotationConfigId,
490        request: RotationConfigCreateRequest,
491    ) -> Result<RotationConfigDetailView, RotationError> {
492        let config = ValidatedRotationConfig::try_from(request)?;
493
494        let existing = self.get(organization_id, id).await?;
495        if existing.config.cipher_id != config.request().cipher_id
496            || existing.config.target_system_id != config.request().target_system_id
497        {
498            return Err(RotationValidationError::RotationConfigImmutableFieldMismatch.into());
499        }
500
501        let target = self
502            .verified_target(organization_id, config.request().target_system_id)
503            .await?;
504
505        let sealed = {
506            let mut ctx = self.key_store.context_mut();
507            config.seal(
508                &target,
509                SymmetricKeySlotId::Organization(organization_id),
510                &mut ctx,
511            )?
512        };
513        let request = config.request();
514        let response = self
515            .api_configurations
516            .api_client
517            .pam_access_connector_rotation_configs_api()
518            .put(
519                organization_id.into(),
520                id.into(),
521                UpdateRotationConfigRequestModel {
522                    sealed_data: sealed.envelope.to_string(),
523                    wrapped_key: sealed.wrapped_key.to_string(),
524                    schedule_cron: request.schedule_cron.clone(),
525                    rotate_on_access_end: Some(request.rotate_on_access_end),
526                },
527            )
528            .await?;
529
530        let detail = RotationConfigDetail::from_response(organization_id, response)?;
531        Ok(self.key_store.decrypt(&detail)?)
532    }
533
534    /// The target must exist with verified sealed fields, unless still `PendingSeal`.
535    async fn verified_target(
536        &self,
537        organization_id: OrganizationId,
538        target_system_id: TargetSystemId,
539    ) -> Result<TargetSystemView, RotationError> {
540        let response = self
541            .api_configurations
542            .api_client
543            .pam_access_connector_rotation_target_systems_api()
544            .get_all(organization_id.into())
545            .await?;
546
547        let item: PamTargetSystemResponseModel = response
548            .data
549            .unwrap_or_default()
550            .into_iter()
551            .find(|item| item.id == Some(target_system_id.into()))
552            .ok_or(RotationError::TargetSystemNotFound)?;
553
554        let target = self
555            .key_store
556            .decrypt(&TargetSystem::from_response(organization_id, item)?)?;
557
558        if target.status != TargetSystemStatus::PendingSeal && target.sealed.is_none() {
559            return Err(RotationValidationError::TargetSystemSealInvalid.into());
560        }
561
562        Ok(target)
563    }
564
565    /// Pauses a config, so no new rotation jobs are dispatched.
566    pub async fn pause(
567        &self,
568        organization_id: OrganizationId,
569        id: RotationConfigId,
570    ) -> Result<(), RotationError> {
571        self.api_configurations
572            .api_client
573            .pam_access_connector_rotation_configs_api()
574            .pause(organization_id.into(), id.into())
575            .await?;
576
577        Ok(())
578    }
579
580    /// Resumes a paused config.
581    pub async fn resume(
582        &self,
583        organization_id: OrganizationId,
584        id: RotationConfigId,
585    ) -> Result<(), RotationError> {
586        self.api_configurations
587            .api_client
588            .pam_access_connector_rotation_configs_api()
589            .resume(organization_id.into(), id.into())
590            .await?;
591
592        Ok(())
593    }
594
595    /// Dispatches an on-demand rotation now, subject to the server's per-config cooldown.
596    pub async fn rotate_now(
597        &self,
598        organization_id: OrganizationId,
599        id: RotationConfigId,
600    ) -> Result<(), RotationError> {
601        self.api_configurations
602            .api_client
603            .pam_access_connector_rotation_configs_api()
604            .rotate(organization_id.into(), id.into())
605            .await?;
606
607        Ok(())
608    }
609
610    /// Records that an operator rotated a manual-target config's credential out of band, clearing
611    /// its awaiting-manual-rotation state.
612    pub async fn record_manual_rotation(
613        &self,
614        organization_id: OrganizationId,
615        id: RotationConfigId,
616    ) -> Result<(), RotationError> {
617        self.api_configurations
618            .api_client
619            .pam_access_connector_rotation_configs_api()
620            .record_manual(organization_id.into(), id.into())
621            .await?;
622
623        Ok(())
624    }
625
626    /// Deletes a rotation config. The cipher and the target system are untouched; only the link
627    /// between them, and its rotation history, go away.
628    pub async fn delete(
629        &self,
630        organization_id: OrganizationId,
631        id: RotationConfigId,
632    ) -> Result<(), RotationError> {
633        self.api_configurations
634            .api_client
635            .pam_access_connector_rotation_configs_api()
636            .delete(organization_id.into(), id.into())
637            .await?;
638
639        Ok(())
640    }
641
642    /// Which actions a config offers, given the status of its target system. Exposed here because
643    /// a `wasm_record` struct crosses the WASM boundary as plain data, without
644    /// [`RotationConfigView::actions`].
645    pub fn actions(
646        &self,
647        config: RotationConfigView,
648        target_status: Option<TargetSystemStatus>,
649    ) -> RotationConfigActions {
650        config.actions(target_status)
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use bitwarden_api_api::{
657        apis::ApiClient,
658        models::{
659            PamRotationConfigResponseModelListResponseModel, PamRotationJobResponseModel,
660            PamRotationJobStatus as ApiRotationJobStatus, PamRotationSource as ApiRotationSource,
661            PamTargetSystemMethod as ApiTargetSystemMethod,
662            PamTargetSystemResponseModelListResponseModel,
663            PamTargetSystemStatus as ApiTargetSystemStatus,
664        },
665    };
666    use bitwarden_core::key_management::create_test_crypto_with_user_and_org_key;
667    use bitwarden_crypto::{
668        EncString, SymmetricCryptoKey, SymmetricKeyAlgorithm, safe::DataEnvelope,
669    };
670    use bitwarden_pam_sealed::{
671        RotationConfigSealedV1, SealedPasswordPolicy, SealedTargetSystemKind,
672        SealedTargetSystemMethod, TargetSystemSealedV1, seal_target_system, unseal_rotation_config,
673    };
674    use uuid::{Uuid, uuid};
675
676    use super::*;
677    use crate::rotation::{
678        models::{RotationJobStatus, RotationSource},
679        validate::RotationValidationError,
680    };
681
682    fn organization_id() -> OrganizationId {
683        OrganizationId::new(uuid!("11111111-1111-1111-1111-111111111111"))
684    }
685
686    fn config_id() -> RotationConfigId {
687        RotationConfigId::new(uuid!("22222222-2222-2222-2222-222222222222"))
688    }
689
690    fn cipher_id() -> CipherId {
691        CipherId::new(uuid!("33333333-3333-3333-3333-333333333333"))
692    }
693
694    fn target_system_id() -> TargetSystemId {
695        TargetSystemId::new(uuid!("44444444-4444-4444-4444-444444444444"))
696    }
697
698    fn job_id() -> Uuid {
699        uuid!("55555555-5555-5555-5555-555555555555")
700    }
701
702    fn organization_key() -> SymmetricCryptoKey {
703        SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac)
704    }
705
706    fn client(api_client: ApiClient) -> RotationConfigsClient {
707        client_with_org_key(api_client, organization_key())
708    }
709
710    fn client_with_org_key(
711        api_client: ApiClient,
712        organization_key: SymmetricCryptoKey,
713    ) -> RotationConfigsClient {
714        RotationConfigsClient {
715            key_store: create_test_crypto_with_user_and_org_key(
716                SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
717                organization_id(),
718                organization_key,
719            ),
720            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
721        }
722    }
723
724    fn seal_config_with(
725        organization_key: &SymmetricCryptoKey,
726        data: RotationConfigSealedV1,
727    ) -> Sealed {
728        let store = create_test_crypto_with_user_and_org_key(
729            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
730            organization_id(),
731            organization_key.clone(),
732        );
733        let mut ctx = store.context_mut();
734        seal_rotation_config::<KeySlotIds>(
735            data,
736            &SymmetricKeySlotId::Organization(organization_id()),
737            &mut ctx,
738        )
739        .expect("sealing succeeds")
740    }
741
742    fn seal_target_with(
743        organization_key: &SymmetricCryptoKey,
744        data: TargetSystemSealedV1,
745    ) -> Sealed {
746        let store = create_test_crypto_with_user_and_org_key(
747            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
748            organization_id(),
749            organization_key.clone(),
750        );
751        let mut ctx = store.context_mut();
752        seal_target_system::<KeySlotIds>(
753            data,
754            &SymmetricKeySlotId::Organization(organization_id()),
755            &mut ctx,
756        )
757        .expect("sealing succeeds")
758    }
759
760    fn sample_sealed_data() -> RotationConfigSealedV1 {
761        RotationConfigSealedV1 {
762            target_system_id: target_system_id().into(),
763            cipher_id: cipher_id().into(),
764            account_identity: "svc_rotation".to_string(),
765            terminate_sessions: true,
766        }
767    }
768
769    fn config_view(
770        organization_id: OrganizationId,
771        response: PamRotationConfigResponseModel,
772        ctx: &mut KeyStoreContext<KeySlotIds>,
773    ) -> Result<RotationConfigView, RotationError> {
774        let config = RotationConfig::from_response(organization_id, response)?;
775        let key = config.key_identifier();
776        Ok(config.decrypt(ctx, key)?)
777    }
778
779    fn detail_view(
780        organization_id: OrganizationId,
781        response: PamRotationConfigDetailResponseModel,
782        ctx: &mut KeyStoreContext<KeySlotIds>,
783    ) -> Result<RotationConfigDetailView, RotationError> {
784        let detail = RotationConfigDetail::from_response(organization_id, response)?;
785        let key = detail.key_identifier();
786        Ok(detail.decrypt(ctx, key)?)
787    }
788
789    /// A payload with every optional field populated, so a test that clears one is unambiguous
790    /// about which field it is exercising.
791    fn sample_response(organization_key: &SymmetricCryptoKey) -> PamRotationConfigResponseModel {
792        let payload = seal_config_with(organization_key, sample_sealed_data());
793        PamRotationConfigResponseModel {
794            id: Some(config_id().into()),
795            organization_id: Some(organization_id().into()),
796            cipher_id: Some(cipher_id().into()),
797            target_system_id: Some(target_system_id().into()),
798            target_system_name: Some("Prod SQL".to_string()),
799            target_system_method: Some(ApiTargetSystemMethod::Automatic),
800            sealed_data: Some(payload.envelope.to_string()),
801            wrapped_key: Some(payload.wrapped_key.to_string()),
802            schedule_cron: Some("0 0 0 * * ?".to_string()),
803            rotate_on_access_end: Some(true),
804            next_rotation_at: Some("2026-02-02T00:00:00Z".to_string()),
805            enabled: Some(true),
806            last_rotation_at: Some("2026-02-01T00:00:00Z".to_string()),
807            has_active_job: Some(true),
808            awaiting_manual_rotation: Some(true),
809            creation_date: Some("2026-01-01T00:00:00Z".to_string()),
810            revision_date: Some("2026-01-15T00:00:00Z".to_string()),
811            ..Default::default()
812        }
813    }
814
815    /// The same config as [`sample_response`], as the detail routes return it: the config's own
816    /// fields flattened onto the payload, plus its rotation history.
817    fn sample_detail_response(
818        organization_key: &SymmetricCryptoKey,
819        jobs: Option<Vec<PamRotationJobResponseModel>>,
820    ) -> PamRotationConfigDetailResponseModel {
821        let config = sample_response(organization_key);
822        PamRotationConfigDetailResponseModel {
823            object: config.object,
824            id: config.id,
825            organization_id: config.organization_id,
826            cipher_id: config.cipher_id,
827            target_system_id: config.target_system_id,
828            target_system_name: config.target_system_name,
829            target_system_method: config.target_system_method,
830            sealed_data: config.sealed_data,
831            wrapped_key: config.wrapped_key,
832            schedule_cron: config.schedule_cron,
833            rotate_on_access_end: config.rotate_on_access_end,
834            next_rotation_at: config.next_rotation_at,
835            enabled: config.enabled,
836            last_rotation_at: config.last_rotation_at,
837            has_active_job: config.has_active_job,
838            awaiting_manual_rotation: config.awaiting_manual_rotation,
839            creation_date: config.creation_date,
840            revision_date: config.revision_date,
841            jobs,
842        }
843    }
844
845    fn sample_job(id: Uuid) -> PamRotationJobResponseModel {
846        PamRotationJobResponseModel {
847            id: Some(id),
848            rotation_config_id: Some(config_id().into()),
849            source: Some(ApiRotationSource::OnDemand),
850            status: Some(ApiRotationJobStatus::Pending),
851            creation_date: Some("2026-01-10T00:00:00Z".to_string()),
852            ..Default::default()
853        }
854    }
855
856    fn create_request() -> RotationConfigCreateRequest {
857        RotationConfigCreateRequest {
858            cipher_id: cipher_id(),
859            target_system_id: target_system_id(),
860            account_identity: "svc_rotation".to_string(),
861            terminate_sessions: true,
862            schedule_cron: Some("0 0 0 * * ?".to_string()),
863            rotate_on_access_end: true,
864        }
865    }
866
867    /// A sealed, active, automatic target supporting session termination.
868    fn terminable_target_response(
869        organization_key: &SymmetricCryptoKey,
870    ) -> PamTargetSystemResponseModel {
871        let payload = seal_target_with(
872            organization_key,
873            TargetSystemSealedV1 {
874                target_system_id: target_system_id().into(),
875                method: SealedTargetSystemMethod::Automatic,
876                kind: Some(SealedTargetSystemKind::CustomScript),
877                password_policy: SealedPasswordPolicy {
878                    min_length: 14,
879                    max_length: 20,
880                    include_uppercase: true,
881                    include_lowercase: true,
882                    include_digits: true,
883                    include_symbols: false,
884                },
885                supports_session_termination: Some(true),
886            },
887        );
888        PamTargetSystemResponseModel {
889            id: Some(target_system_id().into()),
890            organization_id: Some(organization_id().into()),
891            name: Some("Prod SQL".to_string()),
892            method: Some(ApiTargetSystemMethod::Automatic),
893            status: Some(ApiTargetSystemStatus::Active),
894            sealed_data: Some(payload.envelope.to_string()),
895            wrapped_key: Some(payload.wrapped_key.to_string()),
896            creation_date: Some("2026-01-01T00:00:00Z".to_string()),
897            revision_date: Some("2026-01-01T00:00:00Z".to_string()),
898            ..Default::default()
899        }
900    }
901
902    fn target_systems_response(
903        targets: Vec<PamTargetSystemResponseModel>,
904    ) -> PamTargetSystemResponseModelListResponseModel {
905        PamTargetSystemResponseModelListResponseModel {
906            data: Some(targets),
907            ..Default::default()
908        }
909    }
910
911    #[test]
912    fn a_full_payload_maps_every_field() {
913        let organization_key = organization_key();
914        let store = create_test_crypto_with_user_and_org_key(
915            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
916            organization_id(),
917            organization_key.clone(),
918        );
919        let mut ctx = store.context_mut();
920
921        let config = config_view(
922            organization_id(),
923            sample_response(&organization_key),
924            &mut ctx,
925        )
926        .expect("the payload is valid");
927
928        assert_eq!(config.id, config_id());
929        assert_eq!(config.organization_id, organization_id());
930        assert_eq!(config.cipher_id, cipher_id());
931        assert_eq!(config.target_system_id, target_system_id());
932        assert_eq!(config.target_system_name, "Prod SQL");
933        assert_eq!(config.target_system_method, TargetSystemMethod::Automatic);
934        let sealed = config.sealed.expect("the blob is sealed");
935        assert_eq!(sealed.account_identity, "svc_rotation");
936        assert!(sealed.terminate_sessions);
937        assert_eq!(config.schedule_cron.as_deref(), Some("0 0 0 * * ?"));
938        assert!(config.rotate_on_access_end);
939        assert!(config.enabled);
940        assert_eq!(
941            config.last_rotation_at,
942            Some("2026-02-01T00:00:00Z".parse().expect("a valid timestamp"))
943        );
944        assert_eq!(
945            config.next_rotation_at,
946            Some("2026-02-02T00:00:00Z".parse().expect("a valid timestamp"))
947        );
948        assert!(config.has_active_job);
949        assert!(config.awaiting_manual_rotation);
950        assert_eq!(
951            config.creation_date,
952            "2026-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
953        );
954        assert_eq!(
955            config.revision_date,
956            "2026-01-15T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
957        );
958    }
959
960    /// A blob sealed for a different cipher.
961    #[test]
962    fn an_invalid_blob_degrades_to_no_sealed_fields() {
963        let organization_key = organization_key();
964        let wrong_cipher_blob = seal_config_with(
965            &organization_key,
966            RotationConfigSealedV1 {
967                cipher_id: uuid!("99999999-9999-9999-9999-999999999999"),
968                ..sample_sealed_data()
969            },
970        );
971
972        let sample = sample_response(&organization_key);
973        for (sealed_data, wrapped_key) in [
974            (
975                Some("not a sealed blob".to_string()),
976                sample.wrapped_key.clone(),
977            ),
978            (
979                Some(wrong_cipher_blob.envelope.to_string()),
980                Some(wrong_cipher_blob.wrapped_key.to_string()),
981            ),
982            (None, sample.wrapped_key.clone()),
983            (sample.sealed_data.clone(), None),
984        ] {
985            let store = create_test_crypto_with_user_and_org_key(
986                SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
987                organization_id(),
988                organization_key.clone(),
989            );
990            let mut ctx = store.context_mut();
991
992            let response = PamRotationConfigResponseModel {
993                sealed_data,
994                wrapped_key,
995                ..sample_response(&organization_key)
996            };
997
998            let config =
999                config_view(organization_id(), response, &mut ctx).expect("the payload is valid");
1000
1001            assert_eq!(config.sealed, None);
1002        }
1003    }
1004
1005    /// A wrapped key from a different seal of the same data can't open this envelope.
1006    #[test]
1007    fn a_wrapped_key_from_a_different_seal_degrades_to_no_sealed_fields() {
1008        let organization_key = organization_key();
1009        let store = create_test_crypto_with_user_and_org_key(
1010            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1011            organization_id(),
1012            organization_key.clone(),
1013        );
1014        let mut ctx = store.context_mut();
1015
1016        let other = seal_config_with(&organization_key, sample_sealed_data());
1017        let response = PamRotationConfigResponseModel {
1018            wrapped_key: Some(other.wrapped_key.to_string()),
1019            ..sample_response(&organization_key)
1020        };
1021
1022        let config =
1023            config_view(organization_id(), response, &mut ctx).expect("the payload is valid");
1024
1025        assert_eq!(config.sealed, None);
1026    }
1027
1028    /// A hostile server serves org B's item, with org B's validly-sealed blob, in org A's list.
1029    #[test]
1030    fn a_blob_from_a_different_organization_is_not_trusted() {
1031        let org_a = organization_id();
1032        let org_b = OrganizationId::new(uuid!("66666666-6666-6666-6666-666666666666"));
1033        let org_a_key = organization_key();
1034        let org_b_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
1035
1036        // The caller holds both orgs' keys.
1037        let org_b_blob = seal_config_with(&org_b_key, sample_sealed_data());
1038
1039        let store = KeyStore::<KeySlotIds>::default();
1040        {
1041            let mut ctx = store.context_mut();
1042            #[allow(deprecated)]
1043            ctx.set_symmetric_key(SymmetricKeySlotId::Organization(org_a), org_a_key)
1044                .expect("mutable context");
1045            #[allow(deprecated)]
1046            ctx.set_symmetric_key(SymmetricKeySlotId::Organization(org_b), org_b_key)
1047                .expect("mutable context");
1048        }
1049
1050        let response = PamRotationConfigResponseModel {
1051            organization_id: Some(org_b.into()),
1052            sealed_data: Some(org_b_blob.envelope.to_string()),
1053            wrapped_key: Some(org_b_blob.wrapped_key.to_string()),
1054            ..sample_response(&organization_key())
1055        };
1056
1057        let mut ctx = store.context_mut();
1058        let config = config_view(org_a, response, &mut ctx)
1059            .expect("the payload is still structurally valid");
1060
1061        assert_eq!(config.organization_id, org_a);
1062        assert_eq!(config.sealed, None);
1063    }
1064
1065    #[test]
1066    fn an_omitted_enabled_flag_means_the_config_is_active() {
1067        let organization_key = organization_key();
1068        let store = create_test_crypto_with_user_and_org_key(
1069            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1070            organization_id(),
1071            organization_key.clone(),
1072        );
1073        let mut ctx = store.context_mut();
1074
1075        let config = config_view(
1076            organization_id(),
1077            PamRotationConfigResponseModel {
1078                enabled: None,
1079                ..sample_response(&organization_key)
1080            },
1081            &mut ctx,
1082        )
1083        .expect("the payload is valid");
1084
1085        assert!(config.enabled);
1086        assert!(config.actions(Some(TargetSystemStatus::Active)).can_pause);
1087        assert!(!config.actions(Some(TargetSystemStatus::Active)).can_resume);
1088    }
1089
1090    #[test]
1091    fn the_remaining_absent_fields_default_to_not_set() {
1092        let organization_key = organization_key();
1093        let store = create_test_crypto_with_user_and_org_key(
1094            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1095            organization_id(),
1096            organization_key.clone(),
1097        );
1098        let mut ctx = store.context_mut();
1099
1100        let config = config_view(
1101            organization_id(),
1102            PamRotationConfigResponseModel {
1103                target_system_name: None,
1104                schedule_cron: None,
1105                rotate_on_access_end: None,
1106                last_rotation_at: None,
1107                next_rotation_at: None,
1108                has_active_job: None,
1109                awaiting_manual_rotation: None,
1110                ..sample_response(&organization_key)
1111            },
1112            &mut ctx,
1113        )
1114        .expect("the payload is valid");
1115
1116        assert_eq!(config.target_system_name, "");
1117        assert_eq!(config.schedule_cron, None);
1118        assert!(!config.rotate_on_access_end);
1119        assert_eq!(config.last_rotation_at, None);
1120        assert_eq!(config.next_rotation_at, None);
1121        assert!(!config.has_active_job);
1122        assert!(!config.awaiting_manual_rotation);
1123    }
1124
1125    #[test]
1126    fn a_missing_required_field_is_reported_rather_than_defaulted() {
1127        let organization_key = organization_key();
1128        let store = create_test_crypto_with_user_and_org_key(
1129            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1130            organization_id(),
1131            organization_key.clone(),
1132        );
1133        let mut ctx = store.context_mut();
1134
1135        for response in [
1136            PamRotationConfigResponseModel {
1137                id: None,
1138                ..sample_response(&organization_key)
1139            },
1140            // `organization_id: None` degrades instead; see
1141            // `a_blob_from_a_different_organization_is_not_trusted`.
1142            PamRotationConfigResponseModel {
1143                cipher_id: None,
1144                ..sample_response(&organization_key)
1145            },
1146            PamRotationConfigResponseModel {
1147                target_system_id: None,
1148                ..sample_response(&organization_key)
1149            },
1150            PamRotationConfigResponseModel {
1151                target_system_method: None,
1152                ..sample_response(&organization_key)
1153            },
1154            PamRotationConfigResponseModel {
1155                creation_date: None,
1156                ..sample_response(&organization_key)
1157            },
1158            PamRotationConfigResponseModel {
1159                revision_date: None,
1160                ..sample_response(&organization_key)
1161            },
1162        ] {
1163            assert!(matches!(
1164                config_view(organization_id(), response, &mut ctx),
1165                Err(RotationError::MissingField(_))
1166            ));
1167        }
1168    }
1169
1170    #[test]
1171    fn an_unparseable_date_is_reported() {
1172        let organization_key = organization_key();
1173        let store = create_test_crypto_with_user_and_org_key(
1174            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1175            organization_id(),
1176            organization_key.clone(),
1177        );
1178        let mut ctx = store.context_mut();
1179
1180        for response in [
1181            PamRotationConfigResponseModel {
1182                creation_date: Some("not a date".to_string()),
1183                ..sample_response(&organization_key)
1184            },
1185            PamRotationConfigResponseModel {
1186                revision_date: Some("not a date".to_string()),
1187                ..sample_response(&organization_key)
1188            },
1189            PamRotationConfigResponseModel {
1190                last_rotation_at: Some("not a date".to_string()),
1191                ..sample_response(&organization_key)
1192            },
1193            PamRotationConfigResponseModel {
1194                next_rotation_at: Some("not a date".to_string()),
1195                ..sample_response(&organization_key)
1196            },
1197        ] {
1198            assert!(matches!(
1199                config_view(organization_id(), response, &mut ctx),
1200                Err(RotationError::Chrono(_))
1201            ));
1202        }
1203    }
1204
1205    #[test]
1206    fn an_unrecognized_method_degrades_to_unknown() {
1207        let organization_key = organization_key();
1208        let store = create_test_crypto_with_user_and_org_key(
1209            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1210            organization_id(),
1211            organization_key.clone(),
1212        );
1213        let mut ctx = store.context_mut();
1214
1215        let config = config_view(
1216            organization_id(),
1217            PamRotationConfigResponseModel {
1218                target_system_method: Some(ApiTargetSystemMethod::__Unknown(9)),
1219                ..sample_response(&organization_key)
1220            },
1221            &mut ctx,
1222        )
1223        .expect("an unknown method still maps");
1224
1225        assert_eq!(config.target_system_method, TargetSystemMethod::Unknown);
1226    }
1227
1228    /// The detail conversion hand-copies each field of the flattened payload, so a dropped one
1229    /// would silently read as absent.
1230    #[test]
1231    fn the_detail_payload_yields_the_same_config_as_the_list_payload() {
1232        let organization_key = organization_key();
1233        let store = create_test_crypto_with_user_and_org_key(
1234            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1235            organization_id(),
1236            organization_key.clone(),
1237        );
1238        let mut ctx = store.context_mut();
1239
1240        let detail = detail_view(
1241            organization_id(),
1242            sample_detail_response(&organization_key, Some(vec![sample_job(job_id())])),
1243            &mut ctx,
1244        )
1245        .expect("the payload is valid");
1246
1247        assert_eq!(
1248            detail.config,
1249            config_view(
1250                organization_id(),
1251                sample_response(&organization_key),
1252                &mut ctx
1253            )
1254            .expect("the payload is valid")
1255        );
1256    }
1257
1258    #[test]
1259    fn detail_jobs_keep_the_order_the_server_sent() {
1260        let organization_key = organization_key();
1261        let store = create_test_crypto_with_user_and_org_key(
1262            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1263            organization_id(),
1264            organization_key.clone(),
1265        );
1266        let mut ctx = store.context_mut();
1267
1268        let second = uuid!("66666666-6666-6666-6666-666666666666");
1269        let detail = detail_view(
1270            organization_id(),
1271            sample_detail_response(
1272                &organization_key,
1273                Some(vec![sample_job(job_id()), sample_job(second)]),
1274            ),
1275            &mut ctx,
1276        )
1277        .expect("the payload is valid");
1278
1279        let ids: Vec<Uuid> = detail.jobs.iter().map(|job| job.id.into()).collect();
1280        assert_eq!(ids, [job_id(), second]);
1281        assert_eq!(detail.jobs[0].source, RotationSource::OnDemand);
1282        assert_eq!(detail.jobs[0].status, RotationJobStatus::Pending);
1283    }
1284
1285    /// A config that has never rotated has no history, which is not an error.
1286    #[test]
1287    fn a_detail_payload_without_jobs_has_no_jobs() {
1288        let organization_key = organization_key();
1289        let store = create_test_crypto_with_user_and_org_key(
1290            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1291            organization_id(),
1292            organization_key.clone(),
1293        );
1294        let mut ctx = store.context_mut();
1295
1296        let detail = detail_view(
1297            organization_id(),
1298            sample_detail_response(&organization_key, None),
1299            &mut ctx,
1300        )
1301        .expect("no jobs is fine");
1302
1303        assert!(detail.jobs.is_empty());
1304    }
1305
1306    #[test]
1307    fn a_malformed_job_fails_the_whole_detail() {
1308        let organization_key = organization_key();
1309        let store = create_test_crypto_with_user_and_org_key(
1310            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1311            organization_id(),
1312            organization_key.clone(),
1313        );
1314        let mut ctx = store.context_mut();
1315
1316        let malformed = PamRotationJobResponseModel {
1317            rotation_config_id: None,
1318            ..sample_job(job_id())
1319        };
1320
1321        let result = detail_view(
1322            organization_id(),
1323            sample_detail_response(&organization_key, Some(vec![malformed])),
1324            &mut ctx,
1325        );
1326
1327        assert!(matches!(result, Err(RotationError::MissingField(_))));
1328    }
1329
1330    #[tokio::test]
1331    async fn list_maps_the_organizations_configs() {
1332        let organization_key = organization_key();
1333        let api_client = {
1334            let organization_key = organization_key.clone();
1335            ApiClient::new_mocked(move |mock| {
1336                mock.pam_access_connector_rotation_configs_api
1337                    .expect_get_all()
1338                    .withf(|org_id| *org_id == Uuid::from(organization_id()))
1339                    .returning(move |_org_id| {
1340                        Ok(PamRotationConfigResponseModelListResponseModel {
1341                            data: Some(vec![sample_response(&organization_key)]),
1342                            ..Default::default()
1343                        })
1344                    })
1345                    .once();
1346            })
1347        };
1348
1349        let configs = client_with_org_key(api_client, organization_key)
1350            .list(organization_id())
1351            .await
1352            .expect("the list succeeds");
1353
1354        assert_eq!(configs.len(), 1);
1355        assert_eq!(configs[0].id, config_id());
1356        assert!(configs[0].sealed.is_some());
1357    }
1358
1359    #[tokio::test]
1360    async fn an_organization_with_no_configs_lists_empty() {
1361        let api_client = ApiClient::new_mocked(move |mock| {
1362            mock.pam_access_connector_rotation_configs_api
1363                .expect_get_all()
1364                .returning(move |_org_id| {
1365                    Ok(PamRotationConfigResponseModelListResponseModel::default())
1366                })
1367                .once();
1368        });
1369
1370        let configs = client(api_client)
1371            .list(organization_id())
1372            .await
1373            .expect("an absent list is not an error");
1374
1375        assert!(configs.is_empty());
1376    }
1377
1378    #[tokio::test]
1379    async fn list_returns_the_rest_when_one_item_has_an_invalid_blob() {
1380        let organization_key = organization_key();
1381        let api_client = {
1382            let organization_key = organization_key.clone();
1383            ApiClient::new_mocked(move |mock| {
1384                mock.pam_access_connector_rotation_configs_api
1385                    .expect_get_all()
1386                    .returning(move |_org_id| {
1387                        let bad = PamRotationConfigResponseModel {
1388                            id: Some(uuid!("77777777-7777-7777-7777-777777777777")),
1389                            sealed_data: Some("not a sealed blob".to_string()),
1390                            ..sample_response(&organization_key)
1391                        };
1392                        Ok(PamRotationConfigResponseModelListResponseModel {
1393                            data: Some(vec![sample_response(&organization_key), bad]),
1394                            ..Default::default()
1395                        })
1396                    })
1397                    .once();
1398            })
1399        };
1400
1401        let configs = client_with_org_key(api_client, organization_key)
1402            .list(organization_id())
1403            .await
1404            .expect("one invalid item does not fail the list");
1405
1406        assert_eq!(configs.len(), 2);
1407        assert!(configs[0].sealed.is_some());
1408        assert_eq!(configs[1].sealed, None);
1409    }
1410
1411    #[tokio::test]
1412    async fn list_fails_if_a_required_field_is_missing() {
1413        let organization_key = organization_key();
1414        let api_client = {
1415            let organization_key = organization_key.clone();
1416            ApiClient::new_mocked(move |mock| {
1417                mock.pam_access_connector_rotation_configs_api
1418                    .expect_get_all()
1419                    .returning(move |_org_id| {
1420                        Ok(PamRotationConfigResponseModelListResponseModel {
1421                            data: Some(vec![PamRotationConfigResponseModel {
1422                                cipher_id: None,
1423                                ..sample_response(&organization_key)
1424                            }]),
1425                            ..Default::default()
1426                        })
1427                    })
1428                    .once();
1429            })
1430        };
1431
1432        let result = client_with_org_key(api_client, organization_key)
1433            .list(organization_id())
1434            .await;
1435
1436        assert!(matches!(result, Err(RotationError::MissingField(_))));
1437    }
1438
1439    #[tokio::test]
1440    async fn list_surfaces_an_api_error() {
1441        let api_client = ApiClient::new_mocked(move |mock| {
1442            mock.pam_access_connector_rotation_configs_api
1443                .expect_get_all()
1444                .returning(move |_org_id| {
1445                    Err(bitwarden_api_api::apis::Error::Response(
1446                        bitwarden_api_api::apis::ResponseContent {
1447                            status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1448                            message: String::new(),
1449                        },
1450                    ))
1451                })
1452                .once();
1453        });
1454
1455        let result = client(api_client).list(organization_id()).await;
1456
1457        assert!(matches!(result, Err(RotationError::Api(_))));
1458    }
1459
1460    #[tokio::test]
1461    async fn get_reads_the_requested_config_with_its_history() {
1462        let organization_key = organization_key();
1463        let api_client = {
1464            let organization_key = organization_key.clone();
1465            ApiClient::new_mocked(move |mock| {
1466                mock.pam_access_connector_rotation_configs_api
1467                    .expect_get()
1468                    .withf(|org_id, id| {
1469                        *org_id == Uuid::from(organization_id()) && *id == Uuid::from(config_id())
1470                    })
1471                    .returning(move |_org_id, _id| {
1472                        Ok(sample_detail_response(
1473                            &organization_key,
1474                            Some(vec![sample_job(job_id())]),
1475                        ))
1476                    })
1477                    .once();
1478            })
1479        };
1480
1481        let detail = client_with_org_key(api_client, organization_key)
1482            .get(organization_id(), config_id())
1483            .await
1484            .expect("the read succeeds");
1485
1486        assert_eq!(detail.config.id, config_id());
1487        assert_eq!(detail.jobs.len(), 1);
1488    }
1489
1490    #[tokio::test]
1491    async fn get_surfaces_an_api_error() {
1492        let api_client = ApiClient::new_mocked(move |mock| {
1493            mock.pam_access_connector_rotation_configs_api
1494                .expect_get()
1495                .returning(move |_org_id, _id| {
1496                    Err(bitwarden_api_api::apis::Error::Response(
1497                        bitwarden_api_api::apis::ResponseContent {
1498                            status: reqwest::StatusCode::NOT_FOUND,
1499                            message: String::new(),
1500                        },
1501                    ))
1502                })
1503                .once();
1504        });
1505
1506        let result = client(api_client).get(organization_id(), config_id()).await;
1507
1508        assert!(matches!(result, Err(RotationError::Api(_))));
1509    }
1510
1511    #[tokio::test]
1512    async fn create_validates_the_target_seals_and_sends_the_request() {
1513        let organization_key = organization_key();
1514        let api_client = {
1515            let organization_key = organization_key.clone();
1516            ApiClient::new_mocked(move |mock| {
1517                let target_key = organization_key.clone();
1518                mock.pam_access_connector_rotation_target_systems_api
1519                    .expect_get_all()
1520                    .returning(move |_org_id| {
1521                        Ok(target_systems_response(vec![terminable_target_response(
1522                            &target_key,
1523                        )]))
1524                    })
1525                    .once();
1526
1527                let config_key = organization_key.clone();
1528                mock.pam_access_connector_rotation_configs_api
1529                    .expect_post()
1530                    .withf(move |org_id, request| {
1531                        if *org_id != Uuid::from(organization_id())
1532                            || request.cipher_id != Uuid::from(cipher_id())
1533                            || request.target_system_id != Uuid::from(target_system_id())
1534                            || request.schedule_cron.as_deref() != Some("0 0 0 * * ?")
1535                        {
1536                            return false;
1537                        }
1538
1539                        let store = create_test_crypto_with_user_and_org_key(
1540                            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1541                            organization_id(),
1542                            config_key.clone(),
1543                        );
1544                        let mut ctx = store.context_mut();
1545                        let unsealed = unseal_rotation_config::<KeySlotIds>(
1546                            &request
1547                                .sealed_data
1548                                .parse::<DataEnvelope>()
1549                                .expect("sealedData is well-formed"),
1550                            &request
1551                                .wrapped_key
1552                                .parse::<EncString>()
1553                                .expect("wrappedKey is well-formed"),
1554                            &SymmetricKeySlotId::Organization(organization_id()),
1555                            &mut ctx,
1556                        )
1557                        .expect("the blob unseals under the organization key");
1558
1559                        unsealed.target_system_id == Uuid::from(target_system_id())
1560                            && unsealed.cipher_id == Uuid::from(cipher_id())
1561                            && unsealed.account_identity == "svc_rotation"
1562                            && unsealed.terminate_sessions
1563                    })
1564                    .returning(move |_org_id, _request| {
1565                        Ok(sample_detail_response(&organization_key, None))
1566                    })
1567                    .once();
1568            })
1569        };
1570
1571        let detail = client_with_org_key(api_client, organization_key)
1572            .create(organization_id(), create_request())
1573            .await
1574            .expect("creation succeeds");
1575
1576        assert_eq!(detail.config.id, config_id());
1577    }
1578
1579    /// The mock has no expectations, so any call to the server fails the test.
1580    #[tokio::test]
1581    async fn create_rejects_an_invalid_request_before_calling_the_server() {
1582        for (request, expected) in [
1583            (
1584                RotationConfigCreateRequest {
1585                    account_identity: "   ".to_string(),
1586                    ..create_request()
1587                },
1588                RotationValidationError::InvalidAccountIdentity,
1589            ),
1590            (
1591                RotationConfigCreateRequest {
1592                    // A 5-field UNIX cron, which Quartz would misread field by field.
1593                    schedule_cron: Some("0 0 * * *".to_string()),
1594                    ..create_request()
1595                },
1596                RotationValidationError::InvalidCron,
1597            ),
1598        ] {
1599            let result = client(ApiClient::new_mocked(|_| {}))
1600                .create(organization_id(), request)
1601                .await;
1602
1603            assert!(
1604                matches!(result, Err(RotationError::Validation(ref actual)) if *actual == expected),
1605                "expected {expected:?}, got {result:?}"
1606            );
1607        }
1608    }
1609
1610    /// Rejected after fetching the target but before writing anything.
1611    #[tokio::test]
1612    async fn create_rejects_terminate_sessions_on_an_unsupported_target() {
1613        let organization_key = organization_key();
1614        let api_client = {
1615            let target_key = organization_key.clone();
1616            ApiClient::new_mocked(move |mock| {
1617                mock.pam_access_connector_rotation_target_systems_api
1618                    .expect_get_all()
1619                    .returning(move |_org_id| {
1620                        let sealed = seal_target_with(
1621                            &target_key,
1622                            TargetSystemSealedV1 {
1623                                target_system_id: target_system_id().into(),
1624                                method: SealedTargetSystemMethod::Automatic,
1625                                kind: Some(SealedTargetSystemKind::CustomScript),
1626                                password_policy: SealedPasswordPolicy {
1627                                    min_length: 14,
1628                                    max_length: 20,
1629                                    include_uppercase: true,
1630                                    include_lowercase: true,
1631                                    include_digits: true,
1632                                    include_symbols: false,
1633                                },
1634                                supports_session_termination: Some(false),
1635                            },
1636                        );
1637                        let response = PamTargetSystemResponseModel {
1638                            sealed_data: Some(sealed.envelope.to_string()),
1639                            wrapped_key: Some(sealed.wrapped_key.to_string()),
1640                            ..terminable_target_response(&target_key)
1641                        };
1642                        Ok(target_systems_response(vec![response]))
1643                    })
1644                    .once();
1645            })
1646        };
1647
1648        let result = client_with_org_key(api_client, organization_key)
1649            .create(organization_id(), create_request())
1650            .await;
1651
1652        assert!(matches!(
1653            result,
1654            Err(RotationError::Validation(
1655                RotationValidationError::SessionTerminationUnsupported
1656            ))
1657        ));
1658    }
1659
1660    /// An unknown capability counts as unsupported.
1661    #[tokio::test]
1662    async fn create_rejects_terminate_sessions_on_a_pending_seal_target() {
1663        let api_client = ApiClient::new_mocked(move |mock| {
1664            mock.pam_access_connector_rotation_target_systems_api
1665                .expect_get_all()
1666                .returning(move |_org_id| {
1667                    Ok(target_systems_response(vec![
1668                        PamTargetSystemResponseModel {
1669                            id: Some(target_system_id().into()),
1670                            organization_id: Some(organization_id().into()),
1671                            name: Some("Prod SQL".to_string()),
1672                            method: Some(ApiTargetSystemMethod::Automatic),
1673                            status: Some(ApiTargetSystemStatus::PendingSeal),
1674                            sealed_data: None,
1675                            creation_date: Some("2026-01-01T00:00:00Z".to_string()),
1676                            revision_date: Some("2026-01-01T00:00:00Z".to_string()),
1677                            ..Default::default()
1678                        },
1679                    ]))
1680                })
1681                .once();
1682        });
1683
1684        let result = client(api_client)
1685            .create(organization_id(), create_request())
1686            .await;
1687
1688        assert!(matches!(
1689            result,
1690            Err(RotationError::Validation(
1691                RotationValidationError::SessionTerminationUnsupported
1692            ))
1693        ));
1694    }
1695
1696    /// `seal` itself refuses `terminate_sessions` against a target that doesn't confirm support.
1697    #[test]
1698    fn seal_rejects_terminate_sessions_without_confirmed_support() {
1699        let target = TargetSystemView {
1700            id: target_system_id(),
1701            organization_id: organization_id(),
1702            name: "Prod SQL".to_string(),
1703            method: TargetSystemMethod::Automatic,
1704            status: TargetSystemStatus::PendingSeal,
1705            sealed: None,
1706            creation_date: "2026-01-01T00:00:00Z".parse().unwrap(),
1707            revision_date: "2026-01-01T00:00:00Z".parse().unwrap(),
1708        };
1709        let config = ValidatedRotationConfig::try_from(create_request()).expect("a valid request");
1710
1711        let client = client(ApiClient::new_mocked(|_| {}));
1712        let result = config.seal(
1713            &target,
1714            SymmetricKeySlotId::Organization(organization_id()),
1715            &mut client.key_store.context_mut(),
1716        );
1717
1718        assert!(matches!(
1719            result,
1720            Err(RotationError::Validation(
1721                RotationValidationError::SessionTerminationUnsupported
1722            ))
1723        ));
1724    }
1725
1726    #[tokio::test]
1727    async fn create_fails_when_the_target_system_does_not_exist() {
1728        let api_client = ApiClient::new_mocked(move |mock| {
1729            mock.pam_access_connector_rotation_target_systems_api
1730                .expect_get_all()
1731                .returning(move |_org_id| Ok(target_systems_response(vec![])))
1732                .once();
1733        });
1734
1735        let result = client(api_client)
1736            .create(organization_id(), create_request())
1737            .await;
1738
1739        assert!(matches!(result, Err(RotationError::TargetSystemNotFound)));
1740    }
1741
1742    #[tokio::test]
1743    async fn create_fails_when_the_target_systems_seal_is_invalid() {
1744        let api_client = ApiClient::new_mocked(move |mock| {
1745            mock.pam_access_connector_rotation_target_systems_api
1746                .expect_get_all()
1747                .returning(move |_org_id| {
1748                    Ok(target_systems_response(vec![
1749                        PamTargetSystemResponseModel {
1750                            id: Some(target_system_id().into()),
1751                            organization_id: Some(organization_id().into()),
1752                            name: Some("Prod SQL".to_string()),
1753                            method: Some(ApiTargetSystemMethod::Automatic),
1754                            status: Some(ApiTargetSystemStatus::Active),
1755                            sealed_data: Some("not a sealed blob".to_string()),
1756                            creation_date: Some("2026-01-01T00:00:00Z".to_string()),
1757                            revision_date: Some("2026-01-01T00:00:00Z".to_string()),
1758                            ..Default::default()
1759                        },
1760                    ]))
1761                })
1762                .once();
1763        });
1764
1765        let result = client(api_client)
1766            .create(organization_id(), create_request())
1767            .await;
1768
1769        assert!(matches!(
1770            result,
1771            Err(RotationError::Validation(
1772                RotationValidationError::TargetSystemSealInvalid
1773            ))
1774        ));
1775    }
1776
1777    #[tokio::test]
1778    async fn update_seals_and_sends_the_request_to_the_requested_config() {
1779        let organization_key = organization_key();
1780        let api_client = {
1781            let target_key = organization_key.clone();
1782            let config_key = organization_key.clone();
1783            let response_key = organization_key.clone();
1784            let existing_key = organization_key.clone();
1785            ApiClient::new_mocked(move |mock| {
1786                mock.pam_access_connector_rotation_configs_api
1787                    .expect_get()
1788                    .returning(move |_org_id, _id| Ok(sample_detail_response(&existing_key, None)))
1789                    .once();
1790
1791                mock.pam_access_connector_rotation_target_systems_api
1792                    .expect_get_all()
1793                    .returning(move |_org_id| {
1794                        Ok(target_systems_response(vec![terminable_target_response(
1795                            &target_key,
1796                        )]))
1797                    })
1798                    .once();
1799
1800                mock.pam_access_connector_rotation_configs_api
1801                    .expect_put()
1802                    .withf(move |org_id, id, request| {
1803                        if *org_id != Uuid::from(organization_id())
1804                            || *id != Uuid::from(config_id())
1805                            || request.rotate_on_access_end != Some(true)
1806                        {
1807                            return false;
1808                        }
1809
1810                        let store = create_test_crypto_with_user_and_org_key(
1811                            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
1812                            organization_id(),
1813                            config_key.clone(),
1814                        );
1815                        let mut ctx = store.context_mut();
1816                        let unsealed = unseal_rotation_config::<KeySlotIds>(
1817                            &request
1818                                .sealed_data
1819                                .parse::<DataEnvelope>()
1820                                .expect("sealedData is well-formed"),
1821                            &request
1822                                .wrapped_key
1823                                .parse::<EncString>()
1824                                .expect("wrappedKey is well-formed"),
1825                            &SymmetricKeySlotId::Organization(organization_id()),
1826                            &mut ctx,
1827                        )
1828                        .expect("the blob unseals under the organization key");
1829
1830                        unsealed.account_identity == "svc_rotation"
1831                    })
1832                    .returning(move |_org_id, _id, _request| {
1833                        Ok(sample_detail_response(&response_key, None))
1834                    })
1835                    .once();
1836            })
1837        };
1838
1839        let detail = client_with_org_key(api_client, organization_key)
1840            .update(organization_id(), config_id(), create_request())
1841            .await
1842            .expect("the update succeeds");
1843
1844        assert_eq!(detail.config.id, config_id());
1845    }
1846
1847    #[tokio::test]
1848    async fn update_rejects_an_invalid_request_before_calling_the_server() {
1849        let result = client(ApiClient::new_mocked(|_| {}))
1850            .update(
1851                organization_id(),
1852                config_id(),
1853                RotationConfigCreateRequest {
1854                    account_identity: String::new(),
1855                    ..create_request()
1856                },
1857            )
1858            .await;
1859
1860        assert!(matches!(
1861            result,
1862            Err(RotationError::Validation(
1863                RotationValidationError::InvalidAccountIdentity
1864            ))
1865        ));
1866    }
1867
1868    /// The mock has no target-system or `put` expectation, so reaching either fails the test.
1869    #[tokio::test]
1870    async fn update_rejects_a_target_system_id_different_from_the_existing_config() {
1871        let organization_key = organization_key();
1872        let api_client = {
1873            let organization_key = organization_key.clone();
1874            ApiClient::new_mocked(move |mock| {
1875                mock.pam_access_connector_rotation_configs_api
1876                    .expect_get()
1877                    .returning(move |_org_id, _id| {
1878                        // Existing config names `target_system_id()`.
1879                        Ok(sample_detail_response(&organization_key, None))
1880                    })
1881                    .once();
1882            })
1883        };
1884
1885        let result = client_with_org_key(api_client, organization_key)
1886            .update(
1887                organization_id(),
1888                config_id(),
1889                RotationConfigCreateRequest {
1890                    // Request names a different target system.
1891                    target_system_id: TargetSystemId::new(uuid!(
1892                        "88888888-8888-8888-8888-888888888888"
1893                    )),
1894                    ..create_request()
1895                },
1896            )
1897            .await;
1898
1899        assert!(matches!(
1900            result,
1901            Err(RotationError::Validation(
1902                RotationValidationError::RotationConfigImmutableFieldMismatch
1903            ))
1904        ));
1905    }
1906
1907    /// Same guard, for `cipher_id` instead of `target_system_id`.
1908    #[tokio::test]
1909    async fn update_rejects_a_cipher_id_different_from_the_existing_config() {
1910        let organization_key = organization_key();
1911        let api_client = {
1912            let organization_key = organization_key.clone();
1913            ApiClient::new_mocked(move |mock| {
1914                mock.pam_access_connector_rotation_configs_api
1915                    .expect_get()
1916                    .returning(move |_org_id, _id| {
1917                        // Existing config names `cipher_id()`.
1918                        Ok(sample_detail_response(&organization_key, None))
1919                    })
1920                    .once();
1921            })
1922        };
1923
1924        let result = client_with_org_key(api_client, organization_key)
1925            .update(
1926                organization_id(),
1927                config_id(),
1928                RotationConfigCreateRequest {
1929                    // Request names a different cipher.
1930                    cipher_id: CipherId::new(uuid!("99999999-9999-9999-9999-999999999999")),
1931                    ..create_request()
1932                },
1933            )
1934            .await;
1935
1936        assert!(matches!(
1937            result,
1938            Err(RotationError::Validation(
1939                RotationValidationError::RotationConfigImmutableFieldMismatch
1940            ))
1941        ));
1942    }
1943
1944    #[tokio::test]
1945    async fn pause_targets_the_requested_config() {
1946        let api_client = ApiClient::new_mocked(move |mock| {
1947            mock.pam_access_connector_rotation_configs_api
1948                .expect_pause()
1949                .withf(|org_id, id| {
1950                    *org_id == Uuid::from(organization_id()) && *id == Uuid::from(config_id())
1951                })
1952                .returning(move |_org_id, _id| Ok(()))
1953                .once();
1954        });
1955
1956        client(api_client)
1957            .pause(organization_id(), config_id())
1958            .await
1959            .expect("pausing succeeds");
1960    }
1961
1962    #[tokio::test]
1963    async fn resume_targets_the_requested_config() {
1964        let api_client = ApiClient::new_mocked(move |mock| {
1965            mock.pam_access_connector_rotation_configs_api
1966                .expect_resume()
1967                .withf(|org_id, id| {
1968                    *org_id == Uuid::from(organization_id()) && *id == Uuid::from(config_id())
1969                })
1970                .returning(move |_org_id, _id| Ok(()))
1971                .once();
1972        });
1973
1974        client(api_client)
1975            .resume(organization_id(), config_id())
1976            .await
1977            .expect("resuming succeeds");
1978    }
1979
1980    /// The unit-returning actions differ only in the route they hit, so each test pins its own.
1981    #[tokio::test]
1982    async fn rotate_now_dispatches_an_on_demand_rotation() {
1983        let api_client = ApiClient::new_mocked(move |mock| {
1984            mock.pam_access_connector_rotation_configs_api
1985                .expect_rotate()
1986                .withf(|org_id, id| {
1987                    *org_id == Uuid::from(organization_id()) && *id == Uuid::from(config_id())
1988                })
1989                .returning(move |_org_id, _id| Ok(()))
1990                .once();
1991        });
1992
1993        client(api_client)
1994            .rotate_now(organization_id(), config_id())
1995            .await
1996            .expect("dispatching succeeds");
1997    }
1998
1999    #[tokio::test]
2000    async fn record_manual_rotation_records_against_the_requested_config() {
2001        let api_client = ApiClient::new_mocked(move |mock| {
2002            mock.pam_access_connector_rotation_configs_api
2003                .expect_record_manual()
2004                .withf(|org_id, id| {
2005                    *org_id == Uuid::from(organization_id()) && *id == Uuid::from(config_id())
2006                })
2007                .returning(move |_org_id, _id| Ok(()))
2008                .once();
2009        });
2010
2011        client(api_client)
2012            .record_manual_rotation(organization_id(), config_id())
2013            .await
2014            .expect("recording succeeds");
2015    }
2016
2017    #[tokio::test]
2018    async fn delete_targets_the_requested_config() {
2019        let api_client = ApiClient::new_mocked(move |mock| {
2020            mock.pam_access_connector_rotation_configs_api
2021                .expect_delete()
2022                .withf(|org_id, id| {
2023                    *org_id == Uuid::from(organization_id()) && *id == Uuid::from(config_id())
2024                })
2025                .returning(move |_org_id, _id| Ok(()))
2026                .once();
2027        });
2028
2029        client(api_client)
2030            .delete(organization_id(), config_id())
2031            .await
2032            .expect("deleting succeeds");
2033    }
2034
2035    /// The server's cooldown refusal is the expected failure on this route, so it has to reach the
2036    /// caller rather than read as a dispatched rotation.
2037    #[tokio::test]
2038    async fn rotate_now_surfaces_an_api_error() {
2039        let api_client = ApiClient::new_mocked(move |mock| {
2040            mock.pam_access_connector_rotation_configs_api
2041                .expect_rotate()
2042                .returning(move |_org_id, _id| {
2043                    Err(bitwarden_api_api::apis::Error::Response(
2044                        bitwarden_api_api::apis::ResponseContent {
2045                            status: reqwest::StatusCode::TOO_MANY_REQUESTS,
2046                            message: String::new(),
2047                        },
2048                    ))
2049                })
2050                .once();
2051        });
2052
2053        let result = client(api_client)
2054            .rotate_now(organization_id(), config_id())
2055            .await;
2056
2057        assert!(matches!(result, Err(RotationError::Api(_))));
2058    }
2059
2060    /// The client method exists so non-Rust callers reach the same predicates the Rust API has on
2061    /// [`RotationConfigView::actions`]; it must not drift from them.
2062    #[test]
2063    fn the_client_derives_the_same_actions_as_the_config() {
2064        let organization_key = organization_key();
2065        let store = create_test_crypto_with_user_and_org_key(
2066            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
2067            organization_id(),
2068            organization_key.clone(),
2069        );
2070        let mut ctx = store.context_mut();
2071        let config = config_view(
2072            organization_id(),
2073            sample_response(&organization_key),
2074            &mut ctx,
2075        )
2076        .expect("the payload is valid");
2077
2078        for status in [
2079            None,
2080            Some(TargetSystemStatus::Active),
2081            Some(TargetSystemStatus::Disabled),
2082            Some(TargetSystemStatus::Unknown),
2083        ] {
2084            assert_eq!(
2085                client(ApiClient::new_mocked(|_| {})).actions(config.clone(), status),
2086                config.actions(status),
2087                "for {status:?}"
2088            );
2089        }
2090    }
2091}