Skip to main content

rustlavel_vault/
fake.rs

1//! `Vault::fake()` — a secret store that answers from a script.
2//!
3//! This is `Http::fake()` and `Ai::fake()` for secrets. An application's tests
4//! must never need a running Vault: a store is a server, a token, an unseal
5//! ceremony and a policy, and a test suite that needs all four is a test suite
6//! nobody runs.
7//!
8//! ```ignore
9//! let store = Fake::new()
10//!     .secret("secret/data/myapp", [("database_url", "postgres://…")])
11//!     .store();
12//!
13//! boot(store.client()).await?;
14//!
15//! store.assert_read_once("secret/data/myapp");
16//! ```
17//!
18//! The script is answered at the HTTP layer rather than above it, so everything
19//! under test goes through the real [`VaultClient`] — the same envelope
20//! parsing, the same error mapping, the same headers. A fake that shortcut that
21//! would pass while the real client failed, which is the one thing a fake must
22//! not do.
23
24use crate::client::VaultClient;
25use crate::plugin::Vault;
26use crate::resolve::{BoxFuture, Secret, SecretSource};
27use rustlavel_client::FakeResponse;
28use rustlavel_core::{Json, Result};
29use std::time::Duration;
30
31/// The address a faked client points at. It is never connected to; it exists so
32/// a URL in a failed assertion reads like a real one.
33pub const FAKE_ADDRESS: &str = "https://vault.fake:8200";
34
35/// A script of paths to answers.
36///
37/// The longest path that matches wins, whatever order it was written in. That
38/// matters because the underlying HTTP fake matches a URL by substring:
39/// `secret/data/app` would otherwise answer a read of `secret/data/app2`, and
40/// a test that quietly read the wrong secret is worse than one that fails.
41#[derive(Default)]
42pub struct Fake {
43    routes: Vec<(String, FakeResponse)>,
44    fallback: Option<FakeResponse>,
45}
46
47impl Fake {
48    pub fn new() -> Fake {
49        Fake::default()
50    }
51
52    /// A secret in the shape version 2 of the KV engine answers with — the
53    /// fields nested under `data.data`, which is what `secret/data/…` returns.
54    pub fn secret<K, V, I>(self, path: &str, fields: I) -> Fake
55    where
56        K: Into<String>,
57        V: Into<String>,
58        I: IntoIterator<Item = (K, V)>,
59    {
60        self.answers(path, kv2(fields, Json::Null, 0, false))
61    }
62
63    /// A secret that comes with a lease, the way a dynamic credential does.
64    ///
65    /// `duration` is what the store says the lease is good for; the renewer
66    /// takes it from here.
67    pub fn leased<K, V, I>(
68        self,
69        path: &str,
70        fields: I,
71        lease_id: &str,
72        duration: Duration,
73    ) -> Fake
74    where
75        K: Into<String>,
76        V: Into<String>,
77        I: IntoIterator<Item = (K, V)>,
78    {
79        self.answers(path, kv2(fields, Json::from(lease_id), duration.as_secs(), true))
80    }
81
82    /// Answer this path with a body written out by hand — for an envelope this
83    /// builder does not have a name for.
84    pub fn answers(mut self, path: &str, body: Json) -> Fake {
85        self.routes.push((pattern(path), FakeResponse::json(body)));
86        self
87    }
88
89    /// Nothing is stored at this path.
90    pub fn missing(mut self, path: &str) -> Fake {
91        self.routes.push((pattern(path), FakeResponse::text(r#"{"errors":[]}"#).status(404)));
92        self
93    }
94
95    /// The token may not read this path.
96    pub fn denies(mut self, path: &str) -> Fake {
97        self.routes.push((
98            pattern(path),
99            FakeResponse::text(r#"{"errors":["permission denied"]}"#).status(403),
100        ));
101        self
102    }
103
104    /// Answer this path with a status of your choosing — anything
105    /// [`VaultError`](crate::VaultError) distinguishes.
106    pub fn fails(mut self, path: &str, status: u16, message: &str) -> Fake {
107        let body = Json::object([("errors", Json::Array(vec![Json::from(message)]))]);
108        self.routes.push((pattern(path), FakeResponse::json(body).status(status)));
109        self
110    }
111
112    /// The store is sealed.
113    ///
114    /// This replaces the script rather than adding to it: a sealed store
115    /// answers nothing, and a script where some paths still worked would be
116    /// testing a situation that cannot happen.
117    pub fn sealed(self) -> Fake {
118        Fake {
119            routes: Vec::new(),
120            fallback: Some(FakeResponse::text(r#"{"errors":["Vault is sealed"]}"#).status(503)),
121        }
122    }
123
124    /// The store this script describes.
125    pub fn store(self) -> FakeVault {
126        let mut routes = self.routes;
127        // Longest first. A stable sort, so two paths of the same length keep
128        // the order they were written in.
129        routes.sort_by_key(|(pattern, _)| std::cmp::Reverse(pattern.len()));
130
131        let mut script = rustlavel_client::Fake::new();
132        for (pattern, response) in routes {
133            script = script.on(&pattern, response);
134        }
135        if let Some(fallback) = self.fallback {
136            script = script.fallback(fallback);
137        }
138
139        // No retries. A fake that retried would sleep through a backoff in a
140        // test suite, and would record three reads where the test asserts one.
141        let client = VaultClient::new(FAKE_ADDRESS).retries(0).faking(script);
142        client.set_token("fake-token");
143        FakeVault { client }
144    }
145}
146
147/// A faked store: a client to hand to the code under test, and the record of
148/// what that code asked for.
149pub struct FakeVault {
150    client: VaultClient,
151}
152
153impl FakeVault {
154    /// The client to hand to whatever is under test. Cloning it is cheap and
155    /// every clone shares this record.
156    pub fn client(&self) -> &VaultClient {
157        &self.client
158    }
159
160    /// The paths that were read, in order, as they were written in the config
161    /// file — without the address or the `/v1/` prefix the client adds.
162    pub fn reads(&self) -> Vec<String> {
163        self.client
164            .fake()
165            .expect("a faked client always has a script")
166            .recorded()
167            .iter()
168            .map(|request| read_path(&request.url))
169            .collect()
170    }
171
172    pub fn read_count(&self, path: &str) -> usize {
173        self.reads().iter().filter(|read| *read == path).count()
174    }
175
176    #[track_caller]
177    pub fn assert_read(&self, path: &str) {
178        assert!(
179            self.read_count(path) > 0,
180            "expected a read of {path:?}; the store was asked for {:?}",
181            self.reads()
182        );
183    }
184
185    /// Assert a path was read exactly once.
186    ///
187    /// The assertion behind the caching: three keys taken from one secret must
188    /// cost one round trip, and nothing else here would notice if they stopped.
189    #[track_caller]
190    pub fn assert_read_once(&self, path: &str) {
191        assert_eq!(
192            self.read_count(path),
193            1,
194            "expected exactly one read of {path:?}; the store was asked for {:?}",
195            self.reads()
196        );
197    }
198
199    #[track_caller]
200    pub fn assert_not_read(&self, path: &str) {
201        assert_eq!(self.read_count(path), 0, "did not expect a read of {path:?}");
202    }
203
204    #[track_caller]
205    pub fn assert_reads(&self, expected: usize) {
206        assert_eq!(
207            self.reads().len(),
208            expected,
209            "unexpected number of reads: {:?}",
210            self.reads()
211        );
212    }
213}
214
215impl SecretSource for FakeVault {
216    fn read<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Secret>> {
217        self.client.read(path)
218    }
219}
220
221/// Delegated, so a faked store cannot print a token either.
222impl std::fmt::Debug for FakeVault {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        write!(f, "FakeVault({:?})", self.client)
225    }
226}
227
228impl Vault {
229    /// A store that answers from an empty script — every read fails loudly.
230    pub fn fake() -> FakeVault {
231        Fake::new().store()
232    }
233
234    /// A store that answers from a script written out in advance.
235    pub fn fake_with(fake: Fake) -> FakeVault {
236        fake.store()
237    }
238}
239
240/// A KV version 2 envelope, optionally with a lease.
241fn kv2<K, V, I>(fields: I, lease_id: Json, duration: u64, renewable: bool) -> Json
242where
243    K: Into<String>,
244    V: Into<String>,
245    I: IntoIterator<Item = (K, V)>,
246{
247    let data = Json::object(fields.into_iter().map(|(name, value)| (name, Json::from(value.into()))));
248
249    Json::object([
250        ("lease_id", lease_id),
251        ("lease_duration", Json::from(duration)),
252        ("renewable", Json::from(renewable)),
253        (
254            "data",
255            Json::object([("data", data), ("metadata", Json::object([("version", Json::from(1))]))]),
256        ),
257    ])
258}
259
260/// What the client's URL for a path looks like, which is what the underlying
261/// HTTP fake matches on.
262fn pattern(path: &str) -> String {
263    format!("/v1/{}", path.trim_start_matches('/'))
264}
265
266/// The store path out of a recorded URL, undoing what the client added.
267fn read_path(url: &str) -> String {
268    let path = url.split_once("/v1/").map(|(_, rest)| rest).unwrap_or(url);
269    path.split('?').next().unwrap_or(path).to_string()
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[tokio::test]
277    async fn answers_a_read_from_the_script() {
278        let store = Fake::new()
279            .secret("secret/data/myapp", [("password", "s3cr3t")])
280            .store();
281
282        let secret = store.read("secret/data/myapp").await.unwrap();
283
284        assert_eq!(secret.field("password"), Some("s3cr3t"));
285        assert_eq!(secret.path(), "secret/data/myapp");
286    }
287
288    #[tokio::test]
289    async fn records_which_paths_were_read() {
290        let store = Fake::new()
291            .secret("secret/data/one", [("a", "1")])
292            .secret("secret/data/two", [("b", "2")])
293            .store();
294
295        store.read("secret/data/one").await.unwrap();
296        store.read("secret/data/two").await.unwrap();
297        store.read("secret/data/one").await.unwrap();
298
299        assert_eq!(store.reads(), ["secret/data/one", "secret/data/two", "secret/data/one"]);
300        assert_eq!(store.read_count("secret/data/one"), 2);
301        store.assert_read("secret/data/two");
302        store.assert_not_read("secret/data/three");
303        store.assert_reads(3);
304    }
305
306    #[tokio::test]
307    async fn scripts_a_refusal_a_missing_path_and_a_sealed_store() {
308        let store = Fake::new().denies("secret/data/secret").missing("secret/data/gone").store();
309
310        let denied = store.read("secret/data/secret").await.unwrap_err().to_string();
311        assert!(denied.contains("refused"), "got {denied}");
312
313        let missing = store.read("secret/data/gone").await.unwrap_err().to_string();
314        assert!(missing.contains("nothing is stored at"), "got {missing}");
315
316        let sealed = Fake::new().sealed().store();
317        let error = sealed.read("secret/data/anything").await.unwrap_err().to_string();
318        assert!(error.contains("sealed"), "got {error}");
319        assert_eq!(sealed.reads().len(), 1, "a sealed store is not retried");
320    }
321
322    #[tokio::test]
323    async fn an_unscripted_path_fails_loudly_and_is_not_retried() {
324        // A test that quietly passed because a read went somewhere nobody
325        // scripted is worse than no test.
326        let store = Fake::new().secret("secret/data/known", [("a", "1")]).store();
327
328        let error = store.read("secret/data/surprise").await.unwrap_err().to_string();
329
330        assert!(error.contains("no fake response is scripted"), "got {error}");
331        store.assert_reads(1);
332    }
333
334    #[tokio::test]
335    async fn scripts_a_secret_that_comes_with_a_lease() {
336        let store = Fake::new()
337            .leased(
338                "database/creds/app",
339                [("username", "v-token-app-x"), ("password", "generated")],
340                "database/creds/app/abc",
341                Duration::from_secs(3600),
342            )
343            .store();
344
345        let secret = store.read("database/creds/app").await.unwrap();
346
347        assert_eq!(secret.lease().id, "database/creds/app/abc");
348        assert_eq!(secret.lease().duration, Duration::from_secs(3600));
349        assert!(secret.lease().renewable);
350    }
351
352    #[tokio::test]
353    async fn a_path_is_not_answered_by_a_shorter_one_it_begins_with() {
354        let store = Fake::new()
355            .secret("secret/data/app", [("which", "app")])
356            .secret("secret/data/app2", [("which", "app2")])
357            .store();
358
359        assert_eq!(store.read("secret/data/app").await.unwrap().field("which"), Some("app"));
360        assert_eq!(store.read("secret/data/app2").await.unwrap().field("which"), Some("app2"));
361    }
362
363    #[tokio::test]
364    async fn a_status_can_be_scripted_for_any_path() {
365        let store = Fake::new().fails("secret/data/x", 429, "standby").store();
366
367        let error = store.read("secret/data/x").await.unwrap_err().to_string();
368
369        assert!(error.contains("standby"), "got {error}");
370    }
371
372    #[test]
373    fn a_faked_store_never_prints_its_token() {
374        let printed = format!("{:?}", Vault::fake());
375
376        assert!(!printed.contains("fake-token"), "got {printed}");
377        assert!(printed.contains("redacted"));
378    }
379}