Skip to main content

ironflow_store/memory/
secret_store.rs

1//! [`SecretStore`] trait implementation for [`InMemoryStore`].
2
3#[cfg(feature = "secret-store")]
4use std::collections::hash_map::Entry;
5
6#[cfg(feature = "secret-store")]
7use chrono::Utc;
8#[cfg(feature = "secret-store")]
9use uuid::Uuid;
10
11#[cfg(feature = "secret-store")]
12use tracing::error;
13
14#[cfg(feature = "secret-store")]
15use crate::crypto::{decrypt, encrypt, join_versions};
16use crate::entities::{
17    KeyVersionStatus, PROVIDER_ACCOUNT_SECRET_PREFIX, Page, RotationBatch, RotationRequest, Secret,
18    SecretMetadata,
19};
20use crate::error::StoreError;
21use crate::secret_store::SecretStore;
22use crate::store::StoreFuture;
23
24use super::InMemoryStore;
25
26#[cfg(feature = "secret-store")]
27impl InMemoryStore {
28    /// The configured key ring, or a [`StoreError::Crypto`] naming what is missing.
29    fn require_key_ring(&self) -> Result<&crate::crypto::KeyRing, StoreError> {
30        self.key_ring
31            .as_deref()
32            .ok_or_else(|| StoreError::Crypto("no master key configured".to_string()))
33    }
34}
35
36impl SecretStore for InMemoryStore {
37    fn get_secret(&self, key: &str) -> StoreFuture<'_, Option<Secret>> {
38        let key = key.to_string();
39        Box::pin(async move {
40            #[cfg(feature = "secret-store")]
41            {
42                let ring = self.require_key_ring()?;
43
44                let state = self.state.read().await;
45                let Some(encrypted) = state.secrets.get(&key) else {
46                    return Ok(None);
47                };
48
49                let master_key = ring.key_for(encrypted.key_version).ok_or_else(|| {
50                    StoreError::Crypto(format!(
51                        "secret {key:?} uses key version {} which is not configured",
52                        encrypted.key_version
53                    ))
54                })?;
55
56                let plaintext = decrypt(master_key, &encrypted.encrypted_value, &encrypted.nonce)
57                    .map_err(|e| StoreError::Crypto(e.to_string()))?;
58
59                let value = String::from_utf8(plaintext)
60                    .map_err(|e| StoreError::Crypto(format!("invalid UTF-8: {e}")))?;
61
62                Ok(Some(Secret {
63                    id: encrypted.id,
64                    key: encrypted.key.clone(),
65                    value,
66                    created_at: encrypted.created_at,
67                    updated_at: encrypted.updated_at,
68                }))
69            }
70            #[cfg(not(feature = "secret-store"))]
71            {
72                let _ = key;
73                Err(StoreError::Crypto(
74                    "secret-store feature not enabled".to_string(),
75                ))
76            }
77        })
78    }
79
80    fn set_secret(&self, key: &str, value: &str) -> StoreFuture<'_, Secret> {
81        let key = key.to_string();
82        let value = value.to_string();
83        Box::pin(async move {
84            #[cfg(feature = "secret-store")]
85            {
86                let ring = self.require_key_ring()?;
87
88                let (encrypted_value, nonce) = encrypt(ring.active_key(), value.as_bytes())
89                    .map_err(|e| StoreError::Crypto(e.to_string()))?;
90                let key_version = ring.active_version();
91
92                let now = Utc::now();
93                let mut state = self.state.write().await;
94
95                let entry = state.secrets.entry(key.clone());
96                let encrypted = match entry {
97                    Entry::Occupied(mut occ) => {
98                        let existing = occ.get_mut();
99                        existing.encrypted_value = encrypted_value;
100                        existing.nonce = nonce;
101                        existing.key_version = key_version;
102                        existing.updated_at = now;
103                        existing.clone()
104                    }
105                    Entry::Vacant(vac) => {
106                        let new = super::EncryptedSecret {
107                            id: Uuid::now_v7(),
108                            key: key.clone(),
109                            encrypted_value,
110                            nonce,
111                            key_version,
112                            created_at: now,
113                            updated_at: now,
114                        };
115                        vac.insert(new.clone());
116                        new
117                    }
118                };
119
120                Ok(Secret {
121                    id: encrypted.id,
122                    key: encrypted.key,
123                    value,
124                    created_at: encrypted.created_at,
125                    updated_at: encrypted.updated_at,
126                })
127            }
128            #[cfg(not(feature = "secret-store"))]
129            {
130                let _ = (key, value);
131                Err(StoreError::Crypto(
132                    "secret-store feature not enabled".to_string(),
133                ))
134            }
135        })
136    }
137
138    fn delete_secret(&self, key: &str) -> StoreFuture<'_, bool> {
139        let key = key.to_string();
140        Box::pin(async move {
141            let mut state = self.state.write().await;
142            Ok(state.secrets.remove(&key).is_some())
143        })
144    }
145
146    fn list_secret_keys(&self, prefix: &str) -> StoreFuture<'_, Vec<String>> {
147        let prefix = prefix.to_string();
148        Box::pin(async move {
149            let state = self.state.read().await;
150            let keys: Vec<String> = state
151                .secrets
152                .keys()
153                .filter(|k| k.starts_with(&prefix))
154                .cloned()
155                .collect();
156            Ok(keys)
157        })
158    }
159
160    fn list_secrets(
161        &self,
162        prefix: &str,
163        page: u32,
164        per_page: u32,
165    ) -> StoreFuture<'_, Page<SecretMetadata>> {
166        let prefix = prefix.to_string();
167        Box::pin(async move {
168            let state = self.state.read().await;
169            let mut metadata: Vec<SecretMetadata> = state
170                .secrets
171                .values()
172                .filter(|s| s.key.starts_with(&prefix))
173                .filter(|s| !s.key.starts_with(PROVIDER_ACCOUNT_SECRET_PREFIX))
174                .map(|s| SecretMetadata {
175                    id: s.id,
176                    key: s.key.clone(),
177                    created_at: s.created_at,
178                    updated_at: s.updated_at,
179                })
180                .collect();
181
182            metadata.sort_by(|a, b| a.key.cmp(&b.key));
183
184            let total = metadata.len() as u64;
185            let offset = ((page.saturating_sub(1)) as usize) * (per_page as usize);
186            let items: Vec<SecretMetadata> = metadata
187                .into_iter()
188                .skip(offset)
189                .take(per_page as usize)
190                .collect();
191
192            Ok(Page {
193                items,
194                total,
195                page,
196                per_page,
197            })
198        })
199    }
200
201    fn secret_key_status(&self) -> StoreFuture<'_, KeyVersionStatus> {
202        Box::pin(async move {
203            #[cfg(feature = "secret-store")]
204            {
205                let ring = self.require_key_ring()?;
206
207                let state = self.state.read().await;
208                let mut in_use: Vec<i32> = state.secrets.values().map(|s| s.key_version).collect();
209                in_use.sort_unstable();
210                in_use.dedup();
211
212                Ok(KeyVersionStatus {
213                    active: ring.active_version(),
214                    configured: ring.versions(),
215                    missing: ring.missing_versions(&in_use),
216                    retirable: ring.retirable_versions(&in_use),
217                    in_use,
218                })
219            }
220            #[cfg(not(feature = "secret-store"))]
221            {
222                Err(StoreError::Crypto(
223                    "secret-store feature not enabled".to_string(),
224                ))
225            }
226        })
227    }
228
229    fn rotate_secrets(&self, request: RotationRequest) -> StoreFuture<'_, RotationBatch> {
230        Box::pin(async move {
231            #[cfg(feature = "secret-store")]
232            {
233                let ring = self.require_key_ring()?;
234                let target = ring.key_for(request.to_version).ok_or_else(|| {
235                    StoreError::Crypto(format!(
236                        "target key version {} is not configured (available: {})",
237                        request.to_version,
238                        join_versions(&ring.versions())
239                    ))
240                })?;
241
242                let batch_size = request.effective_batch_size() as usize;
243                let mut state = self.state.write().await;
244
245                // The Postgres implementation walks the stock ordered by id;
246                // mirror that here so both behave the same under a cursor.
247                let mut candidates: Vec<(Uuid, String)> = state
248                    .secrets
249                    .values()
250                    .filter(|s| s.key_version != request.to_version)
251                    .filter(|s| request.after_id.is_none_or(|after| s.id > after))
252                    .map(|s| (s.id, s.key.clone()))
253                    .collect();
254                candidates.sort_unstable_by_key(|(id, _)| *id);
255
256                let remaining = candidates.len().saturating_sub(batch_size) as u64;
257                let mut rotated = 0u64;
258                let mut failed = 0u64;
259                let mut last_id = None;
260
261                for (id, key) in candidates.into_iter().take(batch_size) {
262                    last_id = Some(id);
263
264                    let Some(secret) = state.secrets.get(&key) else {
265                        continue;
266                    };
267                    let source_version = secret.key_version;
268
269                    let plaintext = match ring
270                        .key_for(source_version)
271                        .ok_or_else(|| format!("key version {source_version} is not configured"))
272                        .and_then(|source| {
273                            decrypt(source, &secret.encrypted_value, &secret.nonce)
274                                .map_err(|e| e.to_string())
275                        }) {
276                        Ok(plaintext) => plaintext,
277                        Err(reason) => {
278                            failed += 1;
279                            error!(
280                                secret_key = %key,
281                                key_version = source_version,
282                                reason = %reason,
283                                "secret rotation failed"
284                            );
285                            continue;
286                        }
287                    };
288
289                    let (encrypted_value, nonce) = match encrypt(target, &plaintext) {
290                        Ok(pair) => pair,
291                        Err(e) => {
292                            failed += 1;
293                            error!(
294                                secret_key = %key,
295                                reason = %e,
296                                "secret rotation failed"
297                            );
298                            continue;
299                        }
300                    };
301
302                    let entry = state
303                        .secrets
304                        .get_mut(&key)
305                        .expect("secret was present a moment ago under the same write lock");
306                    entry.encrypted_value = encrypted_value;
307                    entry.nonce = nonce;
308                    entry.key_version = request.to_version;
309                    // updated_at is deliberately untouched: re-encryption is
310                    // not a change to the secret itself.
311                    rotated += 1;
312                }
313
314                Ok(RotationBatch {
315                    to_version: request.to_version,
316                    rotated,
317                    failed,
318                    remaining,
319                    last_id,
320                })
321            }
322            #[cfg(not(feature = "secret-store"))]
323            {
324                let _ = request;
325                Err(StoreError::Crypto(
326                    "secret-store feature not enabled".to_string(),
327                ))
328            }
329        })
330    }
331}
332
333#[cfg(all(test, feature = "secret-store"))]
334mod tests {
335    use crate::crypto::MasterKey;
336    use crate::memory::InMemoryStore;
337    use crate::secret_store::SecretStore;
338
339    fn test_store() -> InMemoryStore {
340        let key = MasterKey::from_bytes(&[42u8; 32]).unwrap();
341        let mut store = InMemoryStore::new();
342        store.set_master_key(key);
343        store
344    }
345
346    #[tokio::test]
347    async fn set_and_get_secret() {
348        let store = test_store();
349        let secret = store.set_secret("my/key", "my-value").await.unwrap();
350        assert_eq!(secret.key, "my/key");
351        assert_eq!(secret.value, "my-value");
352
353        let fetched = store.get_secret("my/key").await.unwrap().unwrap();
354        assert_eq!(fetched.value, "my-value");
355        assert_eq!(fetched.id, secret.id);
356    }
357
358    #[tokio::test]
359    async fn get_missing_secret_returns_none() {
360        let store = test_store();
361        let result = store.get_secret("does/not/exist").await.unwrap();
362        assert!(result.is_none());
363    }
364
365    #[tokio::test]
366    async fn set_secret_updates_existing() {
367        let store = test_store();
368        let first = store.set_secret("token", "v1").await.unwrap();
369        let second = store.set_secret("token", "v2").await.unwrap();
370
371        assert_eq!(first.id, second.id);
372        assert_eq!(second.value, "v2");
373
374        let fetched = store.get_secret("token").await.unwrap().unwrap();
375        assert_eq!(fetched.value, "v2");
376    }
377
378    #[tokio::test]
379    async fn delete_existing_secret() {
380        let store = test_store();
381        store.set_secret("to-delete", "val").await.unwrap();
382
383        let deleted = store.delete_secret("to-delete").await.unwrap();
384        assert!(deleted);
385
386        let fetched = store.get_secret("to-delete").await.unwrap();
387        assert!(fetched.is_none());
388    }
389
390    #[tokio::test]
391    async fn delete_missing_secret_returns_false() {
392        let store = test_store();
393        let deleted = store.delete_secret("nope").await.unwrap();
394        assert!(!deleted);
395    }
396
397    #[tokio::test]
398    async fn list_keys_with_prefix() {
399        let store = test_store();
400        store.set_secret("wf/inbox/token_a", "a").await.unwrap();
401        store.set_secret("wf/inbox/token_b", "b").await.unwrap();
402        store.set_secret("wf/veille/token_c", "c").await.unwrap();
403
404        let mut keys = store.list_secret_keys("wf/inbox/").await.unwrap();
405        keys.sort();
406        assert_eq!(keys, vec!["wf/inbox/token_a", "wf/inbox/token_b"]);
407    }
408
409    #[tokio::test]
410    async fn list_keys_empty_prefix_returns_all() {
411        let store = test_store();
412        store.set_secret("a", "1").await.unwrap();
413        store.set_secret("b", "2").await.unwrap();
414
415        let keys = store.list_secret_keys("").await.unwrap();
416        assert_eq!(keys.len(), 2);
417    }
418
419    #[tokio::test]
420    async fn operations_without_master_key_fail() {
421        let store = InMemoryStore::new();
422        let err = store.get_secret("key").await.unwrap_err();
423        assert!(err.to_string().contains("no master key"));
424
425        let err = store.set_secret("key", "val").await.unwrap_err();
426        assert!(err.to_string().contains("no master key"));
427    }
428
429    #[tokio::test]
430    async fn secret_value_is_encrypted_at_rest() {
431        let store = test_store();
432        store
433            .set_secret("sensitive", "plaintext-value")
434            .await
435            .unwrap();
436
437        let state = store.state.read().await;
438        let encrypted = state.secrets.get("sensitive").unwrap();
439        let as_str = String::from_utf8(encrypted.encrypted_value.clone());
440        assert!(
441            as_str.is_err() || as_str.unwrap() != "plaintext-value",
442            "value must be encrypted at rest"
443        );
444    }
445
446    #[tokio::test]
447    async fn set_secret_with_empty_value() {
448        let store = test_store();
449        let secret = store.set_secret("empty", "").await.unwrap();
450        assert_eq!(secret.value, "");
451
452        let fetched = store.get_secret("empty").await.unwrap().unwrap();
453        assert_eq!(fetched.value, "");
454    }
455
456    #[tokio::test]
457    async fn list_secrets_paginated() {
458        let store = test_store();
459        store.set_secret("a/1", "v").await.unwrap();
460        store.set_secret("a/2", "v").await.unwrap();
461        store.set_secret("a/3", "v").await.unwrap();
462        store.set_secret("b/1", "v").await.unwrap();
463
464        let page = store.list_secrets("a/", 1, 2).await.unwrap();
465        assert_eq!(page.total, 3);
466        assert_eq!(page.items.len(), 2);
467        assert_eq!(page.items[0].key, "a/1");
468        assert_eq!(page.items[1].key, "a/2");
469
470        let page2 = store.list_secrets("a/", 2, 2).await.unwrap();
471        assert_eq!(page2.items.len(), 1);
472        assert_eq!(page2.items[0].key, "a/3");
473    }
474
475    // -- Key versioning and rotation ------------------------------------
476
477    use crate::crypto::KeyRing;
478    use crate::entities::RotationRequest;
479
480    fn hex_key(byte: u8) -> String {
481        format!("{byte:02x}").repeat(32)
482    }
483
484    /// A store whose ring holds versions 1 and 2, with `active` encrypting.
485    fn two_version_store(active: i32) -> InMemoryStore {
486        let spec = format!("1:{},2:{}", hex_key(0xaa), hex_key(0xbb));
487        let mut store = InMemoryStore::new();
488        store.set_key_ring(KeyRing::from_spec(&spec, Some(active)).unwrap());
489        store
490    }
491
492    /// Swap the ring in place, keeping the stored secrets untouched.
493    fn rekey(store: &mut InMemoryStore, active: i32) {
494        let spec = format!("1:{},2:{}", hex_key(0xaa), hex_key(0xbb));
495        store.set_key_ring(KeyRing::from_spec(&spec, Some(active)).unwrap());
496    }
497
498    #[tokio::test]
499    async fn set_master_key_is_legacy_version_one() {
500        let store = test_store();
501        store.set_secret("k", "v").await.unwrap();
502
503        let status = store.secret_key_status().await.unwrap();
504        assert_eq!(status.active, 1);
505        assert_eq!(status.configured, vec![1]);
506        assert_eq!(status.in_use, vec![1]);
507        assert!(status.is_consistent());
508    }
509
510    #[tokio::test]
511    async fn new_secret_uses_active_version() {
512        let store = two_version_store(2);
513        store.set_secret("k", "v").await.unwrap();
514
515        let status = store.secret_key_status().await.unwrap();
516        assert_eq!(status.in_use, vec![2]);
517        assert_eq!(status.retirable, vec![1]);
518    }
519
520    #[tokio::test]
521    async fn secret_written_with_old_version_stays_readable() {
522        let mut store = two_version_store(1);
523        store.set_secret("legacy", "old-value").await.unwrap();
524
525        rekey(&mut store, 2);
526        store.set_secret("fresh", "new-value").await.unwrap();
527
528        assert_eq!(
529            store.get_secret("legacy").await.unwrap().unwrap().value,
530            "old-value"
531        );
532        assert_eq!(
533            store.get_secret("fresh").await.unwrap().unwrap().value,
534            "new-value"
535        );
536
537        let status = store.secret_key_status().await.unwrap();
538        assert_eq!(status.in_use, vec![1, 2]);
539        assert!(status.retirable.is_empty());
540    }
541
542    #[tokio::test]
543    async fn secret_key_status_reports_missing_version() {
544        let mut store = two_version_store(2);
545        store.set_secret("k", "v").await.unwrap();
546
547        // Drop version 2 from the ring while a secret still uses it.
548        store.set_key_ring(KeyRing::from_spec(&format!("1:{}", hex_key(0xaa)), Some(1)).unwrap());
549
550        let status = store.secret_key_status().await.unwrap();
551        assert_eq!(status.missing, vec![2]);
552        assert!(!status.is_consistent());
553    }
554
555    #[tokio::test]
556    async fn secret_with_unconfigured_version_fails_to_read() {
557        let mut store = two_version_store(2);
558        store.set_secret("k", "v").await.unwrap();
559
560        store.set_key_ring(KeyRing::from_spec(&format!("1:{}", hex_key(0xaa)), Some(1)).unwrap());
561
562        let err = store.get_secret("k").await.unwrap_err().to_string();
563        assert!(err.contains("key version 2"));
564        assert!(err.contains("not configured"));
565    }
566
567    #[tokio::test]
568    async fn rotate_moves_whole_stock_to_target_version() {
569        let mut store = two_version_store(1);
570        for i in 0..5 {
571            store
572                .set_secret(&format!("k{i}"), &format!("v{i}"))
573                .await
574                .unwrap();
575        }
576        rekey(&mut store, 2);
577
578        let batch = store.rotate_secrets(RotationRequest::new(2)).await.unwrap();
579        assert_eq!(batch.rotated, 5);
580        assert_eq!(batch.failed, 0);
581        assert_eq!(batch.remaining, 0);
582        assert!(batch.is_complete());
583
584        let status = store.secret_key_status().await.unwrap();
585        assert_eq!(status.in_use, vec![2]);
586        assert_eq!(status.retirable, vec![1]);
587
588        for i in 0..5 {
589            assert_eq!(
590                store
591                    .get_secret(&format!("k{i}"))
592                    .await
593                    .unwrap()
594                    .unwrap()
595                    .value,
596                format!("v{i}")
597            );
598        }
599    }
600
601    #[tokio::test]
602    async fn rotate_is_idempotent() {
603        let mut store = two_version_store(1);
604        store.set_secret("k", "v").await.unwrap();
605        rekey(&mut store, 2);
606
607        let first = store.rotate_secrets(RotationRequest::new(2)).await.unwrap();
608        assert_eq!(first.rotated, 1);
609
610        let second = store.rotate_secrets(RotationRequest::new(2)).await.unwrap();
611        assert_eq!(second.rotated, 0);
612        assert_eq!(second.remaining, 0);
613        assert!(second.last_id.is_none());
614        assert!(second.is_complete());
615
616        assert_eq!(store.get_secret("k").await.unwrap().unwrap().value, "v");
617    }
618
619    #[tokio::test]
620    async fn rotate_on_empty_store_is_a_noop() {
621        let store = two_version_store(2);
622        let batch = store.rotate_secrets(RotationRequest::new(2)).await.unwrap();
623
624        assert_eq!(batch.rotated, 0);
625        assert_eq!(batch.failed, 0);
626        assert_eq!(batch.remaining, 0);
627        assert!(batch.last_id.is_none());
628        assert!(batch.is_complete());
629    }
630
631    #[tokio::test]
632    async fn rotate_batch_by_batch_with_cursor() {
633        let mut store = two_version_store(1);
634        for i in 0..3 {
635            store
636                .set_secret(&format!("k{i}"), &format!("v{i}"))
637                .await
638                .unwrap();
639        }
640        rekey(&mut store, 2);
641
642        let mut cursor = None;
643        let mut batches = 0;
644        loop {
645            let mut request = RotationRequest::new(2).with_batch_size(1);
646            if let Some(id) = cursor {
647                request = request.after(id);
648            }
649            let batch = store.rotate_secrets(request).await.unwrap();
650            batches += 1;
651            assert_eq!(batch.rotated, 1);
652
653            // Every secret stays readable between batches, whichever
654            // version it currently sits on.
655            for i in 0..3 {
656                assert_eq!(
657                    store
658                        .get_secret(&format!("k{i}"))
659                        .await
660                        .unwrap()
661                        .unwrap()
662                        .value,
663                    format!("v{i}")
664                );
665            }
666
667            if batch.is_complete() {
668                break;
669            }
670            cursor = batch.last_id;
671        }
672
673        assert_eq!(batches, 3);
674        assert_eq!(store.secret_key_status().await.unwrap().in_use, vec![2]);
675    }
676
677    #[tokio::test]
678    async fn interrupted_rotation_resumes_from_scratch() {
679        let mut store = two_version_store(1);
680        for i in 0..4 {
681            store
682                .set_secret(&format!("k{i}"), &format!("v{i}"))
683                .await
684                .unwrap();
685        }
686        rekey(&mut store, 2);
687
688        // First half, then the operator kills the CLI: the cursor is lost.
689        let partial = store
690            .rotate_secrets(RotationRequest::new(2).with_batch_size(2))
691            .await
692            .unwrap();
693        assert_eq!(partial.rotated, 2);
694        assert_eq!(partial.remaining, 2);
695
696        // Mixed stock, everything still readable.
697        assert_eq!(store.secret_key_status().await.unwrap().in_use, vec![1, 2]);
698        for i in 0..4 {
699            assert_eq!(
700                store
701                    .get_secret(&format!("k{i}"))
702                    .await
703                    .unwrap()
704                    .unwrap()
705                    .value,
706                format!("v{i}")
707            );
708        }
709
710        // Restarting without a cursor only picks up the leftovers.
711        let resumed = store.rotate_secrets(RotationRequest::new(2)).await.unwrap();
712        assert_eq!(resumed.rotated, 2);
713        assert_eq!(resumed.remaining, 0);
714        assert_eq!(store.secret_key_status().await.unwrap().in_use, vec![2]);
715    }
716
717    #[tokio::test]
718    async fn rotate_preserves_identity_and_timestamps() {
719        let mut store = two_version_store(1);
720        let before = store.set_secret("k", "v").await.unwrap();
721        rekey(&mut store, 2);
722
723        store.rotate_secrets(RotationRequest::new(2)).await.unwrap();
724
725        let after = store.get_secret("k").await.unwrap().unwrap();
726        assert_eq!(after.id, before.id);
727        assert_eq!(after.key, before.key);
728        assert_eq!(after.value, before.value);
729        assert_eq!(after.created_at, before.created_at);
730        assert_eq!(after.updated_at, before.updated_at);
731    }
732
733    #[tokio::test]
734    async fn rotate_changes_the_ciphertext() {
735        let mut store = two_version_store(1);
736        store.set_secret("k", "v").await.unwrap();
737
738        let before = {
739            let state = store.state.read().await;
740            state.secrets["k"].encrypted_value.clone()
741        };
742
743        rekey(&mut store, 2);
744        store.rotate_secrets(RotationRequest::new(2)).await.unwrap();
745
746        let state = store.state.read().await;
747        assert_ne!(state.secrets["k"].encrypted_value, before);
748        assert_eq!(state.secrets["k"].key_version, 2);
749    }
750
751    #[tokio::test]
752    async fn rotate_to_unconfigured_version_fails_without_touching_anything() {
753        let store = two_version_store(1);
754        store.set_secret("k", "v").await.unwrap();
755
756        let err = store
757            .rotate_secrets(RotationRequest::new(9))
758            .await
759            .unwrap_err()
760            .to_string();
761        assert!(err.contains("key version 9"));
762        assert!(err.contains("1, 2"));
763
764        assert_eq!(store.secret_key_status().await.unwrap().in_use, vec![1]);
765        assert_eq!(store.get_secret("k").await.unwrap().unwrap().value, "v");
766    }
767
768    #[tokio::test]
769    async fn rotate_without_key_ring_fails() {
770        let store = InMemoryStore::new();
771        let err = store
772            .rotate_secrets(RotationRequest::new(1))
773            .await
774            .unwrap_err();
775        assert!(err.to_string().contains("no master key"));
776
777        let err = store.secret_key_status().await.unwrap_err();
778        assert!(err.to_string().contains("no master key"));
779    }
780
781    #[tokio::test]
782    async fn rotate_batch_size_is_clamped() {
783        let mut store = two_version_store(1);
784        for i in 0..3 {
785            store.set_secret(&format!("k{i}"), "v").await.unwrap();
786        }
787        rekey(&mut store, 2);
788
789        // 0 clamps up to 1, so exactly one secret moves.
790        let batch = store
791            .rotate_secrets(RotationRequest::new(2).with_batch_size(0))
792            .await
793            .unwrap();
794        assert_eq!(batch.rotated, 1);
795        assert_eq!(batch.remaining, 2);
796    }
797
798    #[tokio::test]
799    async fn undecryptable_secret_is_counted_as_failed_and_skipped() {
800        let mut store = two_version_store(1);
801        store.set_secret("broken", "v").await.unwrap();
802
803        // Version 1 is still configured, but with the wrong key material --
804        // the operator pasted a different key under the same version.
805        let wrong = format!("1:{},2:{}", hex_key(0xcc), hex_key(0xbb));
806        store.set_key_ring(KeyRing::from_spec(&wrong, Some(2)).unwrap());
807
808        let batch = store.rotate_secrets(RotationRequest::new(2)).await.unwrap();
809        assert_eq!(batch.rotated, 0);
810        assert_eq!(batch.failed, 1);
811        // The cursor moved past the failed row, so the loop terminates.
812        assert!(batch.last_id.is_some());
813        assert_eq!(batch.remaining, 0);
814        assert!(batch.is_complete());
815
816        // The row is left exactly as it was: no partial write.
817        let state = store.state.read().await;
818        assert_eq!(state.secrets["broken"].key_version, 1);
819    }
820
821    #[tokio::test]
822    async fn failed_secret_does_not_block_the_rest_of_the_batch() {
823        let mut store = two_version_store(1);
824        store.set_secret("a", "va").await.unwrap();
825        store.set_secret("b", "vb").await.unwrap();
826        store.set_secret("c", "vc").await.unwrap();
827
828        // Move "b" to version 2 so it survives the version 1 key swap below.
829        rekey(&mut store, 2);
830        store.set_secret("b", "vb").await.unwrap();
831
832        // Now version 1 holds the wrong material: "a" and "c" become
833        // undecryptable, "b" is untouched.
834        let wrong = format!("1:{},2:{}", hex_key(0xcc), hex_key(0xbb));
835        store.set_key_ring(KeyRing::from_spec(&wrong, Some(2)).unwrap());
836
837        let batch = store.rotate_secrets(RotationRequest::new(2)).await.unwrap();
838        assert_eq!(batch.failed, 2);
839        assert_eq!(batch.rotated, 0);
840        assert_eq!(batch.remaining, 0);
841
842        assert_eq!(store.get_secret("b").await.unwrap().unwrap().value, "vb");
843    }
844
845    #[tokio::test]
846    async fn rotate_backwards_to_an_older_version() {
847        let mut store = two_version_store(2);
848        store.set_secret("k", "v").await.unwrap();
849        rekey(&mut store, 1);
850
851        let batch = store.rotate_secrets(RotationRequest::new(1)).await.unwrap();
852        assert_eq!(batch.rotated, 1);
853        assert_eq!(store.secret_key_status().await.unwrap().in_use, vec![1]);
854        assert_eq!(store.get_secret("k").await.unwrap().unwrap().value, "v");
855    }
856}