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