Skip to main content

basil_core/core/
reconcile.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Startup catalog reconcile: apply each key's `missing` policy (design §3.7).
6//!
7//! After the [`BackendManager`] is built and **before** the broker binds its
8//! socket, [`BackendManager::reconcile`] walks every catalog key, checks whether
9//! its material exists in the routed [`Backend`], and applies the key's
10//! [`MissingPolicy`]:
11//!
12//! - **present** → no-op.
13//! - **`error`** (default) + absent → collected into a **fatal** reconcile error
14//!   (every missing required key is reported at once, not just the first).
15//! - **`warn`** + absent → a [`tracing::warn`] line; the key's ops fail at request
16//!   time until it exists.
17//! - **`generate`** + absent → create material: a crypto key (`asymmetric` /
18//!   `symmetric`) at its catalog path via the backend's named-create methods, a
19//!   `value` / `public` key by running its `generate` recipe and writing it as the
20//!   first KV-v2 version.
21//!
22//! # Absent vs. unreachable
23//!
24//! The existence probe distinguishes **absent** (a reachable backend with no
25//! material: a `404`, surfaced as [`BackendError::KeyNotFound`]) from
26//! **unreachable / failed** (a transport error or any other backend rejection). A
27//! backend that is *down* during reconcile is a clean **fatal** startup error
28//! ([`ReconcileError::Probe`]). It is never silently treated as "absent" and
29//! generated/over, which would mask an outage and could double-create material
30//! once the backend recovers. Fail closed.
31
32use crate::backend::BackendError;
33use crate::catalog::{Class, Engine, GenerateSpec, KeyAlgorithm, KeyEntry, MissingPolicy};
34use crate::manager::{BackendManager, ManagerError, Routed};
35use basil_proto::{AeadAlgorithm, KeyType};
36
37/// The outcome of a successful reconcile pass: a summary for the startup log.
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39pub struct ReconcileSummary {
40    /// Keys whose material already existed (no action taken).
41    pub present: usize,
42    /// Keys that were absent and **generated** (crypto keys + value/public).
43    pub generated: usize,
44    /// Keys that were absent under `missing="warn"` (logged, left absent).
45    pub warned: usize,
46}
47
48/// A fatal reconcile failure (fail closed).
49///
50/// Either a backend was unreachable while probing for a key (never treated as
51/// "absent"), one or more required keys are missing, or generating an absent
52/// `missing="generate"` key failed.
53#[derive(Debug, thiserror::Error)]
54pub enum ReconcileError {
55    /// One or more `missing="error"` (or default) keys are absent from their
56    /// backend. Every such key is reported, not just the first.
57    #[error("required key(s) absent from backend: {}", .0.join(", "))]
58    RequiredMissing(Vec<String>),
59
60    /// A backend was **unreachable** (or otherwise failed) while probing a key's
61    /// existence. This is *not* "absent": a down backend at startup is fatal, so
62    /// reconcile fails closed rather than generating over a real-but-unreachable
63    /// key.
64    #[error("probing key `{key}` failed (backend unreachable or rejecting): {source}")]
65    Probe {
66        /// The key being probed.
67        key: String,
68        /// The underlying backend error (transport / rejection).
69        source: BackendError,
70    },
71
72    /// Generating material for an absent `missing="generate"` key failed.
73    #[error("generating key `{key}` failed: {source}")]
74    Generate {
75        /// The key being generated.
76        key: String,
77        /// The underlying manager/backend error.
78        source: ManagerError,
79    },
80}
81
82/// Whether a probe found the key present or cleanly absent. A fatal (unreachable)
83/// backend is surfaced as the surrounding [`Result`]'s `Err`, not a variant here.
84enum Existence {
85    Present,
86    Absent,
87}
88
89/// The state of one catalog key in a [`CheckReport`] (the read-only counterpart
90/// to reconcile's apply step).
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum KeyStatus {
93    /// The key's material exists in its backend (a clean probe hit).
94    Present,
95    /// The key's material is absent from its (reachable) backend. The key's
96    /// [`MissingPolicy`] tells the caller how reconcile *would* treat it:
97    /// `error` (would fail startup), `warn` (logged, left absent), or `generate`
98    /// (would be created). A `check` never creates anything.
99    Missing(MissingPolicy),
100}
101
102/// A single `(key, status)` row of a [`CheckReport`].
103#[derive(Debug, Clone)]
104pub struct KeyCheck {
105    /// The dotted catalog name.
106    pub name: String,
107    /// Whether the key is present or absent (and, if absent, its `missing` policy).
108    pub status: KeyStatus,
109}
110
111/// The result of a **read-only** [`BackendManager::check`] pass.
112///
113/// Every catalog key is classified as present or missing (with its `missing`
114/// policy), in catalog name order. Unlike [`BackendManager::reconcile`], `check`
115/// never creates or mutates anything; it only reports. A backend that is
116/// *unreachable* during the probe is still a fatal error (the surrounding
117/// [`Result`]'s `Err`), never silently read as "absent".
118#[derive(Debug, Clone, Default)]
119pub struct CheckReport {
120    /// One row per catalog key, in name order.
121    pub keys: Vec<KeyCheck>,
122}
123
124impl CheckReport {
125    /// Count of keys whose material is present.
126    #[must_use]
127    pub fn present_count(&self) -> usize {
128        self.keys
129            .iter()
130            .filter(|k| k.status == KeyStatus::Present)
131            .count()
132    }
133
134    /// All absent keys, in name order, with their `missing` policy.
135    pub fn missing(&self) -> impl Iterator<Item = (&str, MissingPolicy)> {
136        self.keys.iter().filter_map(|k| match k.status {
137            KeyStatus::Missing(policy) => Some((k.name.as_str(), policy)),
138            KeyStatus::Present => None,
139        })
140    }
141
142    /// The dotted names of absent keys whose `missing` policy is `error`: the
143    /// required keys whose absence would fail startup reconcile. This is the
144    /// predicate `--strict` gates on: a CI check **should fail** iff this is
145    /// non-empty.
146    #[must_use]
147    pub fn required_missing(&self) -> Vec<&str> {
148        self.missing()
149            .filter(|(_, policy)| *policy == MissingPolicy::Error)
150            .map(|(name, _)| name)
151            .collect()
152    }
153
154    /// Whether a `--strict` gate should fail: true iff at least one absent key
155    /// carries the `error` (required) policy. `warn`/`generate`-absent keys do
156    /// **not** trip this: they are not failures of a pre-deploy check.
157    #[must_use]
158    pub fn should_fail_required(&self) -> bool {
159        self.missing()
160            .any(|(_, policy)| policy == MissingPolicy::Error)
161    }
162}
163
164impl BackendManager {
165    /// Reconcile every catalog key against its backend, applying the key's
166    /// [`MissingPolicy`](crate::catalog::MissingPolicy) (§3.7).
167    ///
168    /// Returns a [`ReconcileSummary`] on success. Fails closed with a
169    /// [`ReconcileError`] if a backend is unreachable while probing, if any
170    /// required (`error`) key is absent, or if generating an absent
171    /// `generate` key fails. All required-missing keys are collected and reported
172    /// together.
173    ///
174    /// # Errors
175    ///
176    /// [`ReconcileError::Probe`] (unreachable backend), [`ReconcileError::RequiredMissing`]
177    /// (one or more `error`-policy keys absent), or [`ReconcileError::Generate`]
178    /// (a `generate` key could not be created).
179    pub async fn reconcile(&self) -> Result<ReconcileSummary, ReconcileError> {
180        use crate::catalog::MissingPolicy;
181
182        let mut summary = ReconcileSummary::default();
183        let mut required_missing: Vec<String> = Vec::new();
184
185        // Collect (name, entry) up front: `resolve` borrows `self` immutably and
186        // every op below is also `&self`, so a single immutable walk is fine.
187        let keys: Vec<(String, KeyEntry)> = self
188            .keys()
189            .map(|(name, entry)| (name.clone(), entry.clone()))
190            .collect();
191
192        for (name, entry) in &keys {
193            match self.probe(name).await? {
194                Existence::Present => summary.present += 1,
195                Existence::Absent => match entry.missing {
196                    MissingPolicy::Error => {
197                        required_missing.push(name.clone());
198                    }
199                    MissingPolicy::Warn => {
200                        tracing::warn!(
201                            key = %name,
202                            "catalog key absent from backend (missing=warn); ops will fail until it exists"
203                        );
204                        summary.warned += 1;
205                    }
206                    MissingPolicy::Generate => {
207                        self.generate_missing(name, entry).await?;
208                        tracing::info!(key = %name, "generated absent key (missing=generate)");
209                        summary.generated += 1;
210                    }
211                },
212            }
213        }
214
215        if !required_missing.is_empty() {
216            required_missing.sort();
217            return Err(ReconcileError::RequiredMissing(required_missing));
218        }
219        Ok(summary)
220    }
221
222    /// **Read-only** counterpart to [`reconcile`](Self::reconcile): probe every
223    /// catalog key and report whether it is present or absent (with its
224    /// [`MissingPolicy`](crate::catalog::MissingPolicy)), **without** generating
225    /// or mutating anything. For CI / pre-deploy lint checks.
226    ///
227    /// Reuses the same existence probe as `reconcile` (crypto via `key_metadata`,
228    /// value/public via `kv_get`). A backend that is unreachable during the probe
229    /// is *not* "absent": it is a fatal [`ReconcileError::Probe`] (fail closed),
230    /// exactly as in reconcile: a down backend must never be reported as a clean
231    /// "missing" that a caller might act on.
232    ///
233    /// Returns a [`CheckReport`] over every key in catalog name order. Use
234    /// [`CheckReport::should_fail_required`] to gate a `--strict` CI check.
235    ///
236    /// # Errors
237    ///
238    /// [`ReconcileError::Probe`] if any backend is unreachable (or rejecting)
239    /// while probing a key's existence.
240    pub async fn check(&self) -> Result<CheckReport, ReconcileError> {
241        // Catalog iteration is name-ordered (BTreeMap), so the report is too.
242        let mut report = CheckReport::default();
243        for (name, entry) in self.keys() {
244            let status = match self.probe(name).await? {
245                Existence::Present => KeyStatus::Present,
246                Existence::Absent => KeyStatus::Missing(entry.missing),
247            };
248            report.keys.push(KeyCheck {
249                name: name.clone(),
250                status,
251            });
252        }
253        Ok(report)
254    }
255
256    /// Probe whether `name`'s material exists in its backend.
257    ///
258    /// Crypto keys (`asymmetric` / `symmetric`) probe via `key_metadata` (a
259    /// transit key-info read); `value` / `public` keys probe via `kv_get` of the
260    /// latest version. A [`BackendError::KeyNotFound`] (a backend `404`) is the
261    /// clean **absent** signal; **any other** error means the backend is
262    /// unreachable or rejecting and is a fatal [`ReconcileError::Probe`]: a down
263    /// backend is never silently treated as "absent".
264    async fn probe(&self, name: &str) -> Result<Existence, ReconcileError> {
265        let routed = self.resolve(name).map_err(|source| ReconcileError::Probe {
266            key: name.to_string(),
267            // An UnknownKey/UnknownBackend here is a manager-construction
268            // invariant violation, surfaced through the backend error channel.
269            source: match source {
270                ManagerError::Backend(e) => e,
271                other => BackendError::Backend(other.to_string()),
272            },
273        })?;
274
275        // Probe by where the material actually lives: transit keys via
276        // `key_metadata`, KV-backed material (value/public, and the
277        // materialize-to-use keys) via a `kv_get`. Branch on the effective engine,
278        // not just the class, so a materialize-to-sign key is probed in KV rather
279        // than against a transit name that would 404.
280        let result = match (routed.class(), routed.engine) {
281            // Materialize-to-use keys (sealing X25519, engine=kv2 Ed25519,
282            // §17.7): the private lives in KV, and basil-o86 provisions the
283            // public out of band too. BOTH halves must exist for the key to be
284            // "present": an absent public is as fatal (under missing=error) as an
285            // absent private, since wrap/get_public_key/verify need it.
286            (Class::Asymmetric, Engine::Kv2) | (Class::Sealing, _) => {
287                self.probe_materialize_to_use(&routed).await
288            }
289            (Class::Asymmetric | Class::Symmetric, _) => {
290                routed.backend.key_metadata(routed.path()).await.map(|_| ())
291            }
292            (Class::Value | Class::Public, _) => {
293                routed.backend.kv_get(routed.path(), None).await.map(|_| ())
294            }
295        };
296
297        match result {
298            Ok(()) => Ok(Existence::Present),
299            Err(BackendError::KeyNotFound(_)) => Ok(Existence::Absent),
300            Err(source) => Err(ReconcileError::Probe {
301                key: name.to_string(),
302                source,
303            }),
304        }
305    }
306
307    /// Existence probe for a materialize-to-use key (sealing X25519 / engine=kv2
308    /// Ed25519, §17.7): both the private (at `path`) **and** the
309    /// out-of-band-provisioned public (at `public_path`, basil-o86) must exist in
310    /// KV. A [`BackendError::KeyNotFound`] from either is the clean "absent"
311    /// signal the caller maps to [`Existence::Absent`]; any other error is fatal.
312    /// A materialize key with no `public_path` is a misprovisioned catalog (the
313    /// loader normally rejects it), surfaced as "absent" so it fails closed under
314    /// `missing=error` rather than booting a key whose public can never resolve.
315    async fn probe_materialize_to_use(&self, routed: &Routed<'_>) -> Result<(), BackendError> {
316        // The private half (materialized seed / X25519 private).
317        routed.backend.kv_get(routed.path(), None).await?;
318        // The public half, provisioned out of band.
319        let public_path = routed.public_path().ok_or_else(|| {
320            BackendError::KeyNotFound(format!(
321                "{} has no public_path; its public half is not provisioned",
322                routed.path()
323            ))
324        })?;
325        routed.backend.kv_get(public_path, None).await?;
326        Ok(())
327    }
328
329    /// Create material for an absent `missing="generate"` key: a crypto key at its
330    /// catalog path, or a value/public secret from its `generate` recipe written
331    /// as the first KV-v2 version.
332    async fn generate_missing(&self, name: &str, entry: &KeyEntry) -> Result<(), ReconcileError> {
333        let to_err = |source| ReconcileError::Generate {
334            key: name.to_string(),
335            source,
336        };
337        let routed = self.resolve(name).map_err(to_err)?;
338        let path = routed.path().to_string();
339
340        match entry.class {
341            // A materialize-to-sign Ed25519 key (`engine=kv2`, `vault-iiz`) cannot
342            // be generated through transit: Basil has no in-broker keygen for it,
343            // and writing a fresh seed in reconcile would mint signing authority
344            // silently. Its 32-byte seed is provisioned out of band (operator writes
345            // the base64 seed into KV). Fail closed, mirroring the sealing arm.
346            // Never log the (absent) value.
347            Class::Asymmetric if routed.engine == Engine::Kv2 => {
348                return Err(ReconcileError::Generate {
349                    key: name.to_string(),
350                    source: ManagerError::Backend(BackendError::Backend(format!(
351                        "value-store signing key `{name}` (engine=kv2) cannot be \
352                         generated in-broker; provision its Ed25519 seed out of band"
353                    ))),
354                });
355            }
356            Class::Asymmetric => {
357                let key_type = asym_key_type(name, entry.key_type).map_err(to_err)?;
358                routed
359                    .require_mint_key_type("generate", key_type)
360                    .map_err(to_err)?;
361                routed
362                    .backend
363                    .create_named_key(&path, key_type)
364                    .await
365                    .map_err(ManagerError::from)
366                    .map_err(to_err)?;
367            }
368            Class::Symmetric => {
369                let aead = sym_aead(name, entry.key_type).map_err(to_err)?;
370                routed
371                    .backend
372                    .create_named_aead(&path, aead)
373                    .await
374                    .map_err(ManagerError::from)
375                    .map_err(to_err)?;
376            }
377            Class::Sealing => {
378                // Basil cannot generate an X25519 keypair through transit (no such
379                // engine), so a sealing key's private must be provisioned out of
380                // band (BYOK `set` of the raw X25519 private). Fail closed rather
381                // than silently leaving the key absent.
382                return Err(ReconcileError::Generate {
383                    key: name.to_string(),
384                    source: ManagerError::Backend(BackendError::Backend(format!(
385                        "sealing key `{name}` cannot be generated in-broker; \
386                         provision its X25519 private out of band"
387                    ))),
388                });
389            }
390            Class::Value | Class::Public => {
391                // The loader guarantees a value/public key with missing=generate
392                // carries a recipe (GenerateWithoutRecipe is a fatal load error),
393                // so `generate` is Some here; fail closed if it somehow is not.
394                let spec: &GenerateSpec =
395                    entry
396                        .generate
397                        .as_ref()
398                        .ok_or_else(|| ReconcileError::Generate {
399                            key: name.to_string(),
400                            source: ManagerError::Backend(BackendError::Backend(
401                                "missing=generate value/public key has no generate recipe".into(),
402                            )),
403                        })?;
404                for write in self
405                    .generated_writes_for_key(name, spec)
406                    .await
407                    .map_err(to_err)?
408                {
409                    let write_route = self.resolve(&write.key_id).map_err(to_err)?;
410                    write_route
411                        .backend
412                        .kv_put(write_route.path(), &write.value)
413                        .await
414                        .map_err(ManagerError::from)
415                        .map_err(to_err)?;
416                }
417            }
418        }
419        Ok(())
420    }
421}
422
423/// The wire [`KeyType`] for an asymmetric catalog `key_type`. The loader requires
424/// asymmetric keys to carry a `key_type`; an absent or non-signing algorithm here
425/// is a misconfigured catalog and fails closed.
426fn asym_key_type(name: &str, key_type: Option<KeyAlgorithm>) -> Result<KeyType, ManagerError> {
427    match key_type {
428        Some(KeyAlgorithm::Ed25519) => Ok(KeyType::Ed25519),
429        Some(KeyAlgorithm::Ed25519Nkey) => Ok(KeyType::Ed25519Nkey),
430        Some(KeyAlgorithm::Rsa2048) => Ok(KeyType::Rsa2048),
431        Some(KeyAlgorithm::EcdsaP256) => Ok(KeyType::EcdsaP256),
432        Some(KeyAlgorithm::EcdsaP384) => Ok(KeyType::EcdsaP384),
433        Some(KeyAlgorithm::EcdsaP521) => Ok(KeyType::EcdsaP521),
434        // ML-DSA software-custodied signing keys are not transit keys: their seed
435        // is generated, sealed, and written as a custody record by the
436        // local-software provider (the `new_key` RPC path), never created through
437        // a backend `create_named_key` at startup reconcile. Fail closed if a
438        // catalog marks one `missing=generate`.
439        Some(KeyAlgorithm::MlDsa44 | KeyAlgorithm::MlDsa65 | KeyAlgorithm::MlDsa87) => {
440            Err(ManagerError::Backend(BackendError::Backend(format!(
441                "ML-DSA signing key `{name}` is software-custodied; provision it \
442                 via the `new_key` provider path, not startup generate"
443            ))))
444        }
445        Some(
446            KeyAlgorithm::Aes256Gcm
447            | KeyAlgorithm::ChaCha20Poly1305
448            | KeyAlgorithm::X25519
449            | KeyAlgorithm::MlKem512
450            | KeyAlgorithm::MlKem768
451            | KeyAlgorithm::MlKem1024,
452        )
453        | None => Err(ManagerError::Backend(BackendError::Backend(format!(
454            "asymmetric key `{name}` has no signing keyType to generate from"
455        )))),
456    }
457}
458
459/// The [`AeadAlgorithm`] for a symmetric catalog `key_type`. A symmetric key
460/// always carries an AEAD algorithm; anything else fails closed.
461fn sym_aead(name: &str, key_type: Option<KeyAlgorithm>) -> Result<AeadAlgorithm, ManagerError> {
462    match key_type {
463        Some(KeyAlgorithm::Aes256Gcm) => Ok(AeadAlgorithm::Aes256Gcm),
464        Some(KeyAlgorithm::ChaCha20Poly1305) => Ok(AeadAlgorithm::Chacha20Poly1305),
465        _ => Err(ManagerError::Backend(BackendError::Backend(format!(
466            "symmetric key `{name}` has no AEAD keyType to generate from"
467        )))),
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::backend::{Backend, KeyMetadata, KvValue, NewKey, PublicKey};
475    use crate::catalog::Catalog;
476    use async_trait::async_trait;
477    use std::collections::BTreeMap;
478    use std::sync::Mutex;
479    use std::sync::atomic::{AtomicUsize, Ordering};
480
481    /// How a mock backend answers an existence probe (`key_metadata` / `kv_get`).
482    #[derive(Clone, Copy)]
483    enum Probe {
484        /// The key exists, so probes return `Ok` (Present).
485        Present,
486        /// The key is absent, so probes return `KeyNotFound` (a backend 404).
487        Absent,
488        /// The backend is unreachable, so probes return a transport error (fatal).
489        Unreachable,
490    }
491
492    /// A reconcile-focused mock: a single backend whose probe disposition is fixed
493    /// at construction, recording every create/write op so a test can assert which
494    /// generation path ran.
495    #[derive(Default)]
496    struct Recorder {
497        create_named_key_calls: AtomicUsize,
498        create_named_aead_calls: AtomicUsize,
499        kv_put_calls: AtomicUsize,
500        last_create_path: Mutex<Option<String>>,
501        last_kv_put: Mutex<Option<(String, Vec<u8>)>>,
502        last_aead: Mutex<Option<AeadAlgorithm>>,
503        last_key_type: Mutex<Option<KeyType>>,
504    }
505
506    struct MockBackend {
507        probe: Probe,
508        rec: std::sync::Arc<Recorder>,
509    }
510
511    impl MockBackend {
512        fn new(probe: Probe) -> (Self, std::sync::Arc<Recorder>) {
513            let rec = std::sync::Arc::new(Recorder::default());
514            (
515                Self {
516                    probe,
517                    rec: rec.clone(),
518                },
519                rec,
520            )
521        }
522    }
523
524    #[async_trait]
525    impl Backend for MockBackend {
526        fn kind(&self) -> &'static str {
527            "mock"
528        }
529
530        async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
531            Err(BackendError::Unsupported(
532                "new_key (unused in reconcile tests)",
533            ))
534        }
535
536        async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
537            Err(BackendError::Unsupported("public_key"))
538        }
539
540        async fn public_key_with_meta(&self, _key_id: &str) -> Result<PublicKey, BackendError> {
541            Err(BackendError::Unsupported("public_key_with_meta"))
542        }
543
544        /// The crypto-key existence probe.
545        async fn key_metadata(&self, _key_id: &str) -> Result<KeyMetadata, BackendError> {
546            match self.probe {
547                Probe::Present => Ok(KeyMetadata {
548                    key_type: Some(KeyType::Ed25519),
549                    latest_version: 1,
550                }),
551                Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
552                Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
553            }
554        }
555
556        /// The value/public existence probe.
557        async fn kv_get(
558            &self,
559            _key_id: &str,
560            _version: Option<u32>,
561        ) -> Result<KvValue, BackendError> {
562            match self.probe {
563                Probe::Present => Ok(KvValue {
564                    value: b"present".to_vec(),
565                    version: 1,
566                }),
567                Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
568                Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
569            }
570        }
571
572        /// Sealing is never reconciled (a sealing private is operator-provisioned
573        /// out-of-band), so this mock mirrors the value/public probe shape.
574        async fn kv_get_secret(
575            &self,
576            _key_id: &str,
577            _version: Option<u32>,
578        ) -> Result<crate::backend::KvSecret, BackendError> {
579            match self.probe {
580                Probe::Present => Ok(crate::backend::KvSecret {
581                    value: zeroize::Zeroizing::new(b"present".to_vec()),
582                    version: 1,
583                }),
584                Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
585                Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
586            }
587        }
588
589        async fn create_named_key(
590            &self,
591            key_id: &str,
592            key_type: KeyType,
593        ) -> Result<NewKey, BackendError> {
594            self.rec
595                .create_named_key_calls
596                .fetch_add(1, Ordering::SeqCst);
597            *self.rec.last_create_path.lock().unwrap() = Some(key_id.to_string());
598            *self.rec.last_key_type.lock().unwrap() = Some(key_type);
599            Ok(NewKey {
600                key_id: key_id.to_string(),
601                public_key: vec![1, 2, 3],
602            })
603        }
604
605        async fn create_named_aead(
606            &self,
607            key_id: &str,
608            aead: AeadAlgorithm,
609        ) -> Result<(), BackendError> {
610            self.rec
611                .create_named_aead_calls
612                .fetch_add(1, Ordering::SeqCst);
613            *self.rec.last_create_path.lock().unwrap() = Some(key_id.to_string());
614            *self.rec.last_aead.lock().unwrap() = Some(aead);
615            Ok(())
616        }
617
618        async fn kv_put(&self, key_id: &str, value: &[u8]) -> Result<u32, BackendError> {
619            self.rec.kv_put_calls.fetch_add(1, Ordering::SeqCst);
620            *self.rec.last_kv_put.lock().unwrap() = Some((key_id.to_string(), value.to_vec()));
621            Ok(1)
622        }
623
624        async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
625            Err(BackendError::Unsupported("sign"))
626        }
627
628        async fn verify(
629            &self,
630            _key_id: &str,
631            _message: &[u8],
632            _signature: &[u8],
633        ) -> Result<bool, BackendError> {
634            Err(BackendError::Unsupported("verify"))
635        }
636    }
637
638    /// A catalog with one key of each (class, missing) combination this reconcile
639    /// exercises, all routed to the single mock backend `b`.
640    const CATALOG: &str = r#"{
641      "schemaVersion": 1,
642      "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
643      "keys": {
644        "req.signer": {
645          "class": "asymmetric", "keyType": "ed25519", "backend": "b",
646          "path": "req-signer", "writable": true, "missing": "error",
647          "description": "a required signing key"
648        },
649        "warn.value": {
650          "class": "value", "backend": "b", "engine": "kv2",
651          "path": "secret/data/warn/value", "writable": true, "missing": "warn",
652          "description": "a warn-on-missing value"
653        },
654        "gen.signer": {
655          "class": "asymmetric", "keyType": "ed25519", "backend": "b",
656          "path": "gen-signer", "writable": true, "missing": "generate",
657          "description": "a generate-on-missing signing key"
658        },
659        "gen.box": {
660          "class": "symmetric", "keyType": "aes-256-gcm", "backend": "b",
661          "path": "gen-box", "writable": true, "missing": "generate",
662          "description": "a generate-on-missing AEAD key"
663        },
664        "gen.value": {
665          "class": "value", "backend": "b", "engine": "kv2",
666          "path": "secret/data/gen/value", "writable": true, "missing": "generate",
667          "generate": { "format": "ascii-printable", "bytes": 24 },
668          "description": "a generate-on-missing value"
669        }
670      }
671    }"#;
672
673    fn parse() -> Catalog {
674        serde_json::from_str(CATALOG).expect("catalog parses")
675    }
676
677    /// Build a manager over the fixture catalog, every key routed to one mock with
678    /// the given probe disposition; returns the manager + the recorder.
679    fn manager_with(probe: Probe) -> (BackendManager, std::sync::Arc<Recorder>) {
680        let (backend, rec) = MockBackend::new(probe);
681        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
682        backends.insert("b".into(), Box::new(backend));
683        let mgr = BackendManager::new(parse(), backends).expect("manager constructs");
684        (mgr, rec)
685    }
686
687    #[tokio::test]
688    async fn all_present_is_a_noop_with_present_count() {
689        let (mgr, rec) = manager_with(Probe::Present);
690        let summary = mgr.reconcile().await.expect("all-present reconciles ok");
691        assert_eq!(summary.present, 5);
692        assert_eq!(summary.generated, 0);
693        assert_eq!(summary.warned, 0);
694        // Nothing was created when every key already existed.
695        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
696        assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
697        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
698    }
699
700    #[tokio::test]
701    async fn required_missing_is_fatal_and_reports_the_key() {
702        // Every key is absent: req.signer (missing=error) must make reconcile fail,
703        // and the error names it. warn/generate keys do NOT make it fail.
704        let (mgr, _rec) = manager_with(Probe::Absent);
705        let err = mgr
706            .reconcile()
707            .await
708            .expect_err("a required-missing key must fail closed");
709        match err {
710            ReconcileError::RequiredMissing(keys) => {
711                assert_eq!(keys, vec!["req.signer".to_string()]);
712            }
713            other => panic!("expected RequiredMissing, got {other:?}"),
714        }
715    }
716
717    #[tokio::test]
718    async fn warn_missing_is_ok_and_counted() {
719        // Make ONLY the warn key absent by using a catalog where the error key is
720        // dropped; here we instead assert the warn count surfaces when the run
721        // otherwise succeeds. Use a generate-everything probe path: with all keys
722        // absent, the warn key is logged + counted but the required key still
723        // fails. So test warn in isolation via a catalog with no required keys.
724        const NO_REQUIRED: &str = r#"{
725          "schemaVersion": 1,
726          "backends": {
727            "b": {
728              "kind": "vault", "addr": "http://127.0.0.1:8200",
729              "engines": ["transit", "kv2"], "capabilities": [],
730              "mintKeyTypes": ["ed25519"]
731            }
732          },
733          "keys": {
734            "warn.value": {
735              "class": "value", "backend": "b", "engine": "kv2",
736              "path": "secret/data/warn/value", "writable": true, "missing": "warn",
737              "description": "a warn-on-missing value"
738            }
739          }
740        }"#;
741        let cat: Catalog = serde_json::from_str(NO_REQUIRED).expect("parses");
742        let (backend, _rec) = MockBackend::new(Probe::Absent);
743        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
744        backends.insert("b".into(), Box::new(backend));
745        let mgr = BackendManager::new(cat, backends).expect("constructs");
746        let summary = mgr.reconcile().await.expect("warn-only reconciles ok");
747        assert_eq!(summary.warned, 1);
748        assert_eq!(summary.present, 0);
749        assert_eq!(summary.generated, 0);
750    }
751
752    #[tokio::test]
753    async fn generate_missing_creates_crypto_and_value_keys() {
754        // A catalog with ONLY generate keys (asym + sym + value), all absent: each
755        // must be created via its class-specific path.
756        const GEN_ONLY: &str = r#"{
757          "schemaVersion": 1,
758          "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
759          "keys": {
760            "gen.signer": {
761              "class": "asymmetric", "keyType": "ed25519", "backend": "b",
762              "path": "gen-signer", "writable": true, "missing": "generate",
763              "description": "asym generate"
764            },
765            "gen.box": {
766              "class": "symmetric", "keyType": "aes-256-gcm", "backend": "b",
767              "path": "gen-box", "writable": true, "missing": "generate",
768              "description": "sym generate"
769            },
770            "gen.value": {
771              "class": "value", "backend": "b", "engine": "kv2",
772              "path": "secret/data/gen/value", "writable": true, "missing": "generate",
773              "generate": { "format": "ascii-printable", "bytes": 24 },
774              "description": "value generate"
775            }
776          }
777        }"#;
778        let cat: Catalog = serde_json::from_str(GEN_ONLY).expect("parses");
779        let (backend, rec) = MockBackend::new(Probe::Absent);
780        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
781        backends.insert("b".into(), Box::new(backend));
782        let mgr = BackendManager::new(cat, backends).expect("constructs");
783
784        let summary = mgr.reconcile().await.expect("generate reconciles ok");
785        assert_eq!(summary.generated, 3);
786        assert_eq!(summary.present, 0);
787        assert_eq!(summary.warned, 0);
788
789        // Crypto asym -> create_named_key at the catalog PATH.
790        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
791        assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::Ed25519));
792        // Crypto sym -> create_named_aead with the catalog AEAD suite.
793        assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 1);
794        assert_eq!(
795            *rec.last_aead.lock().unwrap(),
796            Some(AeadAlgorithm::Aes256Gcm)
797        );
798        // Value -> generate_value + kv_put at the catalog PATH, a 24-byte printable.
799        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 1);
800        let (path, value) = rec.last_kv_put.lock().unwrap().clone().expect("a kv write");
801        assert_eq!(path, "secret/data/gen/value");
802        assert_eq!(value.len(), 24);
803        assert!(value.iter().all(|b| (b'!'..=b'~').contains(b)));
804    }
805
806    fn generated_asymmetric_catalog(key_type: &str, mint_key_types: &str) -> String {
807        format!(
808            r#"{{
809          "schemaVersion": 1,
810          "backends": {{
811            "b": {{
812              "kind": "vault", "addr": "http://127.0.0.1:8200",
813              "engines": ["transit"], "capabilities": [],
814              "mintKeyTypes": [{mint_key_types}]
815            }}
816          }},
817          "keys": {{
818            "gen.signer": {{
819              "class": "asymmetric", "keyType": "{key_type}", "backend": "b",
820              "path": "gen-signer", "writable": true, "missing": "generate",
821              "description": "generated signer"
822            }}
823          }}
824        }}"#
825        )
826    }
827
828    #[tokio::test]
829    async fn generate_missing_creates_rsa_when_static_preset_allows_it() {
830        let cat: Catalog =
831            serde_json::from_str(&generated_asymmetric_catalog("rsa-2048", r#""rsa-2048""#))
832                .expect("parses");
833        let (backend, rec) = MockBackend::new(Probe::Absent);
834        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
835        backends.insert("b".into(), Box::new(backend));
836        let mgr = BackendManager::new(cat, backends).expect("constructs");
837
838        let summary = mgr.reconcile().await.expect("rsa generate reconciles ok");
839        assert_eq!(summary.generated, 1);
840        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
841        assert_eq!(
842            rec.last_create_path.lock().unwrap().as_deref(),
843            Some("gen-signer")
844        );
845        assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::Rsa2048));
846    }
847
848    #[tokio::test]
849    async fn generate_missing_creates_ecdsa_p256_when_static_preset_allows_it() {
850        let cat: Catalog = serde_json::from_str(&generated_asymmetric_catalog(
851            "ecdsa-p256",
852            r#""ecdsa-p256""#,
853        ))
854        .expect("parses");
855        let (backend, rec) = MockBackend::new(Probe::Absent);
856        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
857        backends.insert("b".into(), Box::new(backend));
858        let mgr = BackendManager::new(cat, backends).expect("constructs");
859
860        let summary = mgr.reconcile().await.expect("ecdsa generate reconciles ok");
861        assert_eq!(summary.generated, 1);
862        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
863        assert_eq!(
864            rec.last_create_path.lock().unwrap().as_deref(),
865            Some("gen-signer")
866        );
867        assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::EcdsaP256));
868    }
869
870    #[tokio::test]
871    async fn generate_missing_creates_nkey_when_static_preset_allows_it() {
872        let cat: Catalog = serde_json::from_str(&generated_asymmetric_catalog(
873            "ed25519-nkey",
874            r#""ed25519-nkey""#,
875        ))
876        .expect("parses");
877        let (backend, rec) = MockBackend::new(Probe::Absent);
878        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
879        backends.insert("b".into(), Box::new(backend));
880        let mgr = BackendManager::new(cat, backends).expect("constructs");
881
882        let summary = mgr.reconcile().await.expect("nkey generate reconciles ok");
883        assert_eq!(summary.generated, 1);
884        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
885        assert_eq!(
886            rec.last_create_path.lock().unwrap().as_deref(),
887            Some("gen-signer")
888        );
889        assert_eq!(
890            *rec.last_key_type.lock().unwrap(),
891            Some(KeyType::Ed25519Nkey)
892        );
893    }
894
895    #[tokio::test]
896    async fn generate_missing_rejects_key_type_absent_from_static_backend_preset() {
897        const GEN_RSA_UNSUPPORTED: &str = r#"{
898          "schemaVersion": 1,
899          "backends": {
900            "b": {
901              "kind": "vault", "addr": "http://127.0.0.1:8200",
902              "engines": ["transit"], "capabilities": [],
903              "mintKeyTypes": ["ed25519"]
904            }
905          },
906          "keys": {
907            "gen.rsa": {
908              "class": "asymmetric", "keyType": "rsa-2048", "backend": "b",
909              "path": "gen-rsa", "writable": true, "missing": "generate",
910              "description": "rsa generate"
911            }
912          }
913        }"#;
914        let cat: Catalog = serde_json::from_str(GEN_RSA_UNSUPPORTED).expect("parses");
915        let (backend, rec) = MockBackend::new(Probe::Absent);
916        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
917        backends.insert("b".into(), Box::new(backend));
918        let mgr = BackendManager::new(cat, backends).expect("constructs");
919
920        let err = mgr
921            .reconcile()
922            .await
923            .expect_err("rsa is absent from preset");
924        assert!(matches!(
925            err,
926            ReconcileError::Generate {
927                source: ManagerError::UnsupportedKeyType {
928                    backend,
929                    op: "generate",
930                    key_type: KeyType::Rsa2048
931                },
932                ..
933            } if backend == "b"
934        ));
935        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
936    }
937
938    #[tokio::test]
939    async fn kv2_signer_missing_generate_refuses_in_broker() {
940        // A value-store Ed25519 signing key (engine=kv2, vault-iiz) with
941        // missing=generate must FAIL closed: Basil cannot mint its seed in-broker,
942        // and it must NOT call create_named_key (that would route to transit) or
943        // write a fresh seed (silently minting signing authority). The seed is
944        // provisioned out of band.
945        const KV2_SIGNER: &str = r#"{
946          "schemaVersion": 1,
947          "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
948          "keys": {
949            "kv2.signer": {
950              "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
951              "path": "secret/data/kv2/signer", "writable": true, "missing": "generate",
952              "description": "a value-store materialize-to-sign key"
953            }
954          }
955        }"#;
956        let cat: Catalog = serde_json::from_str(KV2_SIGNER).expect("parses");
957        let (backend, rec) = MockBackend::new(Probe::Absent);
958        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
959        backends.insert("b".into(), Box::new(backend));
960        let mgr = BackendManager::new(cat, backends).expect("constructs");
961
962        let err = mgr
963            .reconcile()
964            .await
965            .expect_err("a kv2 signing key cannot be generated in-broker");
966        assert!(matches!(err, ReconcileError::Generate { .. }));
967        // Crucially: NOT created via transit, and no fresh seed written to KV.
968        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
969        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
970    }
971
972    #[tokio::test]
973    async fn kv2_signer_present_probes_kv_not_transit() {
974        // The existence probe for an engine=kv2 signing key must hit KV (kv_get),
975        // not key_metadata (a transit name that would 404). With Probe::Present the
976        // key resolves as present and reconcile is a clean no-op.
977        const KV2_SIGNER: &str = r#"{
978          "schemaVersion": 1,
979          "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
980          "keys": {
981            "kv2.signer": {
982              "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
983              "path": "secret/data/kv2/signer",
984              "publicPath": "secret/data/kv2/signer-public",
985              "writable": true, "missing": "error",
986              "description": "a value-store materialize-to-sign key"
987            }
988          }
989        }"#;
990        let cat: Catalog = serde_json::from_str(KV2_SIGNER).expect("parses");
991        let (backend, rec) = MockBackend::new(Probe::Present);
992        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
993        backends.insert("b".into(), Box::new(backend));
994        let mgr = BackendManager::new(cat, backends).expect("constructs");
995
996        let summary = mgr
997            .reconcile()
998            .await
999            .expect("present kv2 signer reconciles");
1000        assert_eq!(summary.present, 1);
1001        assert_eq!(summary.generated, 0);
1002        // It was probed via kv_get; no transit-style creation happened.
1003        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1004    }
1005
1006    #[tokio::test]
1007    async fn materialize_key_without_public_path_probes_absent() {
1008        // basil-o86: a materialize-to-use key whose public half is NOT provisioned
1009        // (no publicPath) is treated as ABSENT even when the private is Present,
1010        // so under missing=error it fails closed rather than booting a key whose
1011        // wrap/get_public_key can never resolve a public.
1012        const NO_PUB: &str = r#"{
1013          "schemaVersion": 1,
1014          "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1015          "keys": {
1016            "kv2.signer": {
1017              "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
1018              "path": "secret/data/kv2/signer", "writable": true, "missing": "error",
1019              "description": "a kv2 signer missing its publicPath"
1020            }
1021          }
1022        }"#;
1023        let cat: Catalog = serde_json::from_str(NO_PUB).expect("parses");
1024        // Probe::Present: the private would probe present, but the absent publicPath
1025        // makes the key absent overall.
1026        let (backend, _rec) = MockBackend::new(Probe::Present);
1027        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
1028        backends.insert("b".into(), Box::new(backend));
1029        let mgr = BackendManager::new(cat, backends).expect("constructs");
1030
1031        let err = mgr
1032            .reconcile()
1033            .await
1034            .expect_err("a materialize key with no publicPath must fail closed");
1035        match err {
1036            ReconcileError::RequiredMissing(keys) => assert_eq!(keys, vec!["kv2.signer"]),
1037            other => panic!("expected RequiredMissing, got {other:?}"),
1038        }
1039    }
1040
1041    #[tokio::test]
1042    async fn unreachable_backend_is_fatal_not_treated_as_absent() {
1043        // A down backend must FAIL reconcile (Probe error), never be read as
1044        // "absent" and generated/over.
1045        let (mgr, rec) = manager_with(Probe::Unreachable);
1046        let err = mgr
1047            .reconcile()
1048            .await
1049            .expect_err("an unreachable backend must fail closed");
1050        match err {
1051            ReconcileError::Probe { source, .. } => {
1052                assert!(matches!(source, BackendError::Transport(_)));
1053            }
1054            other => panic!("expected Probe (unreachable), got {other:?}"),
1055        }
1056        // Crucially: nothing was generated despite generate-policy keys being
1057        // present: an unreachable backend is never silently "absent".
1058        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1059        assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1060        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1061    }
1062
1063    #[tokio::test]
1064    async fn present_keys_are_never_created() {
1065        // Even generate-policy keys that already exist must NOT be re-created.
1066        let (mgr, rec) = manager_with(Probe::Present);
1067        mgr.reconcile().await.expect("reconciles");
1068        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1069        assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1070        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1071    }
1072
1073    // ---- check(): the read-only report (vault-roe) -------------------------
1074
1075    fn status_of<'a>(report: &'a CheckReport, name: &str) -> &'a KeyStatus {
1076        &report
1077            .keys
1078            .iter()
1079            .find(|k| k.name == name)
1080            .expect("key in report")
1081            .status
1082    }
1083
1084    #[tokio::test]
1085    async fn check_all_present_reports_every_key_present_and_creates_nothing() {
1086        let (mgr, rec) = manager_with(Probe::Present);
1087        let report = mgr.check().await.expect("check succeeds when reachable");
1088        // Every fixture key (5) is present; nothing is missing; nothing fails.
1089        assert_eq!(report.keys.len(), 5);
1090        assert_eq!(report.present_count(), 5);
1091        assert_eq!(report.missing().count(), 0);
1092        assert!(!report.should_fail_required());
1093        assert!(report.required_missing().is_empty());
1094        // A check is read-only: it never creates/mutates anything.
1095        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1096        assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1097        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1098    }
1099
1100    #[tokio::test]
1101    async fn check_all_absent_classifies_each_key_by_its_missing_policy() {
1102        // Every key is absent: the report must classify each by its catalog
1103        // `missing` policy, NOT generate or mutate anything.
1104        let (mgr, rec) = manager_with(Probe::Absent);
1105        let report = mgr.check().await.expect("check succeeds when reachable");
1106
1107        assert_eq!(report.present_count(), 0);
1108        // req.signer (error), warn.value (warn), gen.signer/gen.box/gen.value (generate).
1109        assert_eq!(
1110            *status_of(&report, "req.signer"),
1111            KeyStatus::Missing(MissingPolicy::Error)
1112        );
1113        assert_eq!(
1114            *status_of(&report, "warn.value"),
1115            KeyStatus::Missing(MissingPolicy::Warn)
1116        );
1117        assert_eq!(
1118            *status_of(&report, "gen.signer"),
1119            KeyStatus::Missing(MissingPolicy::Generate)
1120        );
1121        assert_eq!(
1122            *status_of(&report, "gen.box"),
1123            KeyStatus::Missing(MissingPolicy::Generate)
1124        );
1125        assert_eq!(
1126            *status_of(&report, "gen.value"),
1127            KeyStatus::Missing(MissingPolicy::Generate)
1128        );
1129
1130        // Read-only: not one create/write despite generate-policy keys absent.
1131        assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1132        assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1133        assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1134    }
1135
1136    #[tokio::test]
1137    async fn check_should_fail_required_iff_an_error_class_key_is_absent() {
1138        // All absent: req.signer is missing=error -> the --strict gate must trip,
1139        // and required_missing names exactly it (warn/generate-absent excluded).
1140        let (mgr, _rec) = manager_with(Probe::Absent);
1141        let report = mgr.check().await.expect("check ok");
1142        assert!(report.should_fail_required());
1143        assert_eq!(report.required_missing(), vec!["req.signer"]);
1144
1145        // All present: nothing required is absent -> the gate must NOT trip.
1146        let (mgr, _rec) = manager_with(Probe::Present);
1147        let report = mgr.check().await.expect("check ok");
1148        assert!(!report.should_fail_required());
1149        assert!(report.required_missing().is_empty());
1150    }
1151
1152    #[tokio::test]
1153    async fn check_warn_and_generate_absent_do_not_trip_require() {
1154        // A catalog with ONLY warn + generate keys, all absent: missing keys are
1155        // reported, but NONE is error-class, so --strict must NOT fail.
1156        const WARN_GEN_ONLY: &str = r#"{
1157          "schemaVersion": 1,
1158          "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1159          "keys": {
1160            "warn.value": {
1161              "class": "value", "backend": "b", "engine": "kv2",
1162              "path": "secret/data/warn/value", "writable": true, "missing": "warn",
1163              "description": "warn"
1164            },
1165            "gen.signer": {
1166              "class": "asymmetric", "keyType": "ed25519", "backend": "b",
1167              "path": "gen-signer", "writable": true, "missing": "generate",
1168              "description": "generate"
1169            }
1170          }
1171        }"#;
1172        let cat: Catalog = serde_json::from_str(WARN_GEN_ONLY).expect("parses");
1173        let (backend, _rec) = MockBackend::new(Probe::Absent);
1174        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
1175        backends.insert("b".into(), Box::new(backend));
1176        let mgr = BackendManager::new(cat, backends).expect("constructs");
1177
1178        let report = mgr.check().await.expect("check ok");
1179        // Both keys absent and reported, but neither is error-class.
1180        assert_eq!(report.missing().count(), 2);
1181        assert!(!report.should_fail_required());
1182        assert!(report.required_missing().is_empty());
1183    }
1184
1185    #[tokio::test]
1186    async fn check_unreachable_backend_is_fatal_not_treated_as_absent() {
1187        // A down backend must FAIL check (Probe error), never be reported as a
1188        // clean "missing" that a --strict gate might then wrongly decide.
1189        let (mgr, _rec) = manager_with(Probe::Unreachable);
1190        let err = mgr
1191            .check()
1192            .await
1193            .expect_err("an unreachable backend must fail closed");
1194        match err {
1195            ReconcileError::Probe { source, .. } => {
1196                assert!(matches!(source, BackendError::Transport(_)));
1197            }
1198            other => panic!("expected Probe (unreachable), got {other:?}"),
1199        }
1200    }
1201
1202    #[tokio::test]
1203    async fn check_report_keys_are_in_catalog_name_order() {
1204        // The report walks the catalog (a BTreeMap) so rows are name-sorted:
1205        // a stable, diffable order for CI output.
1206        let (mgr, _rec) = manager_with(Probe::Present);
1207        let report = mgr.check().await.expect("check ok");
1208        let names: Vec<&str> = report.keys.iter().map(|k| k.name.as_str()).collect();
1209        let mut sorted = names.clone();
1210        sorted.sort_unstable();
1211        assert_eq!(names, sorted);
1212    }
1213}