use crate::client::VaultClient;
use crate::plugin::Vault;
use crate::resolve::{BoxFuture, Secret, SecretSource};
use rustlavel_client::FakeResponse;
use rustlavel_core::{Json, Result};
use std::time::Duration;
pub const FAKE_ADDRESS: &str = "https://vault.fake:8200";
#[derive(Default)]
pub struct Fake {
routes: Vec<(String, FakeResponse)>,
fallback: Option<FakeResponse>,
}
impl Fake {
pub fn new() -> Fake {
Fake::default()
}
pub fn secret<K, V, I>(self, path: &str, fields: I) -> Fake
where
K: Into<String>,
V: Into<String>,
I: IntoIterator<Item = (K, V)>,
{
self.answers(path, kv2(fields, Json::Null, 0, false))
}
pub fn leased<K, V, I>(
self,
path: &str,
fields: I,
lease_id: &str,
duration: Duration,
) -> Fake
where
K: Into<String>,
V: Into<String>,
I: IntoIterator<Item = (K, V)>,
{
self.answers(path, kv2(fields, Json::from(lease_id), duration.as_secs(), true))
}
pub fn answers(mut self, path: &str, body: Json) -> Fake {
self.routes.push((pattern(path), FakeResponse::json(body)));
self
}
pub fn missing(mut self, path: &str) -> Fake {
self.routes.push((pattern(path), FakeResponse::text(r#"{"errors":[]}"#).status(404)));
self
}
pub fn denies(mut self, path: &str) -> Fake {
self.routes.push((
pattern(path),
FakeResponse::text(r#"{"errors":["permission denied"]}"#).status(403),
));
self
}
pub fn fails(mut self, path: &str, status: u16, message: &str) -> Fake {
let body = Json::object([("errors", Json::Array(vec![Json::from(message)]))]);
self.routes.push((pattern(path), FakeResponse::json(body).status(status)));
self
}
pub fn sealed(self) -> Fake {
Fake {
routes: Vec::new(),
fallback: Some(FakeResponse::text(r#"{"errors":["Vault is sealed"]}"#).status(503)),
}
}
pub fn store(self) -> FakeVault {
let mut routes = self.routes;
routes.sort_by_key(|(pattern, _)| std::cmp::Reverse(pattern.len()));
let mut script = rustlavel_client::Fake::new();
for (pattern, response) in routes {
script = script.on(&pattern, response);
}
if let Some(fallback) = self.fallback {
script = script.fallback(fallback);
}
let client = VaultClient::new(FAKE_ADDRESS).retries(0).faking(script);
client.set_token("fake-token");
FakeVault { client }
}
}
pub struct FakeVault {
client: VaultClient,
}
impl FakeVault {
pub fn client(&self) -> &VaultClient {
&self.client
}
pub fn reads(&self) -> Vec<String> {
self.client
.fake()
.expect("a faked client always has a script")
.recorded()
.iter()
.map(|request| read_path(&request.url))
.collect()
}
pub fn read_count(&self, path: &str) -> usize {
self.reads().iter().filter(|read| *read == path).count()
}
#[track_caller]
pub fn assert_read(&self, path: &str) {
assert!(
self.read_count(path) > 0,
"expected a read of {path:?}; the store was asked for {:?}",
self.reads()
);
}
#[track_caller]
pub fn assert_read_once(&self, path: &str) {
assert_eq!(
self.read_count(path),
1,
"expected exactly one read of {path:?}; the store was asked for {:?}",
self.reads()
);
}
#[track_caller]
pub fn assert_not_read(&self, path: &str) {
assert_eq!(self.read_count(path), 0, "did not expect a read of {path:?}");
}
#[track_caller]
pub fn assert_reads(&self, expected: usize) {
assert_eq!(
self.reads().len(),
expected,
"unexpected number of reads: {:?}",
self.reads()
);
}
}
impl SecretSource for FakeVault {
fn read<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Secret>> {
self.client.read(path)
}
}
impl std::fmt::Debug for FakeVault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FakeVault({:?})", self.client)
}
}
impl Vault {
pub fn fake() -> FakeVault {
Fake::new().store()
}
pub fn fake_with(fake: Fake) -> FakeVault {
fake.store()
}
}
fn kv2<K, V, I>(fields: I, lease_id: Json, duration: u64, renewable: bool) -> Json
where
K: Into<String>,
V: Into<String>,
I: IntoIterator<Item = (K, V)>,
{
let data = Json::object(fields.into_iter().map(|(name, value)| (name, Json::from(value.into()))));
Json::object([
("lease_id", lease_id),
("lease_duration", Json::from(duration)),
("renewable", Json::from(renewable)),
(
"data",
Json::object([("data", data), ("metadata", Json::object([("version", Json::from(1))]))]),
),
])
}
fn pattern(path: &str) -> String {
format!("/v1/{}", path.trim_start_matches('/'))
}
fn read_path(url: &str) -> String {
let path = url.split_once("/v1/").map(|(_, rest)| rest).unwrap_or(url);
path.split('?').next().unwrap_or(path).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn answers_a_read_from_the_script() {
let store = Fake::new()
.secret("secret/data/myapp", [("password", "s3cr3t")])
.store();
let secret = store.read("secret/data/myapp").await.unwrap();
assert_eq!(secret.field("password"), Some("s3cr3t"));
assert_eq!(secret.path(), "secret/data/myapp");
}
#[tokio::test]
async fn records_which_paths_were_read() {
let store = Fake::new()
.secret("secret/data/one", [("a", "1")])
.secret("secret/data/two", [("b", "2")])
.store();
store.read("secret/data/one").await.unwrap();
store.read("secret/data/two").await.unwrap();
store.read("secret/data/one").await.unwrap();
assert_eq!(store.reads(), ["secret/data/one", "secret/data/two", "secret/data/one"]);
assert_eq!(store.read_count("secret/data/one"), 2);
store.assert_read("secret/data/two");
store.assert_not_read("secret/data/three");
store.assert_reads(3);
}
#[tokio::test]
async fn scripts_a_refusal_a_missing_path_and_a_sealed_store() {
let store = Fake::new().denies("secret/data/secret").missing("secret/data/gone").store();
let denied = store.read("secret/data/secret").await.unwrap_err().to_string();
assert!(denied.contains("refused"), "got {denied}");
let missing = store.read("secret/data/gone").await.unwrap_err().to_string();
assert!(missing.contains("nothing is stored at"), "got {missing}");
let sealed = Fake::new().sealed().store();
let error = sealed.read("secret/data/anything").await.unwrap_err().to_string();
assert!(error.contains("sealed"), "got {error}");
assert_eq!(sealed.reads().len(), 1, "a sealed store is not retried");
}
#[tokio::test]
async fn an_unscripted_path_fails_loudly_and_is_not_retried() {
let store = Fake::new().secret("secret/data/known", [("a", "1")]).store();
let error = store.read("secret/data/surprise").await.unwrap_err().to_string();
assert!(error.contains("no fake response is scripted"), "got {error}");
store.assert_reads(1);
}
#[tokio::test]
async fn scripts_a_secret_that_comes_with_a_lease() {
let store = Fake::new()
.leased(
"database/creds/app",
[("username", "v-token-app-x"), ("password", "generated")],
"database/creds/app/abc",
Duration::from_secs(3600),
)
.store();
let secret = store.read("database/creds/app").await.unwrap();
assert_eq!(secret.lease().id, "database/creds/app/abc");
assert_eq!(secret.lease().duration, Duration::from_secs(3600));
assert!(secret.lease().renewable);
}
#[tokio::test]
async fn a_path_is_not_answered_by_a_shorter_one_it_begins_with() {
let store = Fake::new()
.secret("secret/data/app", [("which", "app")])
.secret("secret/data/app2", [("which", "app2")])
.store();
assert_eq!(store.read("secret/data/app").await.unwrap().field("which"), Some("app"));
assert_eq!(store.read("secret/data/app2").await.unwrap().field("which"), Some("app2"));
}
#[tokio::test]
async fn a_status_can_be_scripted_for_any_path() {
let store = Fake::new().fails("secret/data/x", 429, "standby").store();
let error = store.read("secret/data/x").await.unwrap_err().to_string();
assert!(error.contains("standby"), "got {error}");
}
#[test]
fn a_faked_store_never_prints_its_token() {
let printed = format!("{:?}", Vault::fake());
assert!(!printed.contains("fake-token"), "got {printed}");
assert!(printed.contains("redacted"));
}
}