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