Skip to content

Commit 74ccdcb

Browse files
committed
Introduce new unit tests for the exec backend
1 parent 5055021 commit 74ccdcb

File tree

2 files changed

+174
-0
lines changed

2 files changed

+174
-0
lines changed

src/secrets/exec.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,76 @@ async fn query_backend(
217217
let response = serde_json::from_slice::<HashMap<String, ExecResponse>>(&output)?;
218218
Ok(response)
219219
}
220+
221+
#[cfg(test)]
222+
mod tests {
223+
use crate::{
224+
config::SecretBackend,
225+
secrets::exec::{ExecBackend, ExecVersion},
226+
};
227+
use rstest::rstest;
228+
use std::{
229+
collections::{HashMap, HashSet},
230+
path::PathBuf,
231+
};
232+
use tokio::sync::broadcast;
233+
use vrl::value;
234+
235+
fn make_test_backend(protocol: ExecVersion) -> ExecBackend {
236+
let command_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
237+
.join("tests/behavior/secrets/mock_secrets_exec.py");
238+
ExecBackend {
239+
command: ["python", command_path.to_str().unwrap()]
240+
.map(String::from)
241+
.to_vec(),
242+
timeout: 5,
243+
protocol,
244+
}
245+
}
246+
247+
#[tokio::test(flavor = "multi_thread")]
248+
#[rstest(
249+
protocol,
250+
case(ExecVersion::V1),
251+
case(ExecVersion::V1_1 {
252+
backend_type: "file.json".to_string(),
253+
backend_config: value!({"file_path": "/abc.json"}),
254+
})
255+
)]
256+
async fn test_exec_backend(protocol: ExecVersion) {
257+
let mut backend = make_test_backend(protocol);
258+
let (_tx, mut rx) = broadcast::channel(1);
259+
// These fake secrets are statically contained in mock_secrets_exec.py
260+
let fake_secret_values: HashMap<String, String> = [
261+
("fake_secret_1", "123456"),
262+
("fake_secret_2", "123457"),
263+
("fake_secret_3", "123458"),
264+
("fake_secret_4", "123459"),
265+
("fake_secret_5", "123460"),
266+
]
267+
.into_iter()
268+
.map(|(k, v)| (k.to_string(), v.to_string()))
269+
.collect();
270+
// Calling the mock_secects_exec.py program with the expected secret keys should provide
271+
// the values expected above in `fake_secret_values`
272+
let fetched_keys = backend
273+
.retrieve(fake_secret_values.keys().cloned().collect(), &mut rx)
274+
.await
275+
.unwrap();
276+
// Assert response is as expected
277+
assert_eq!(fetched_keys.len(), 5);
278+
for (fake_secret_key, fake_secret_value) in fake_secret_values {
279+
assert_eq!(fetched_keys.get(&fake_secret_key), Some(&fake_secret_value));
280+
}
281+
}
282+
283+
#[tokio::test(flavor = "multi_thread")]
284+
async fn test_exec_backend_missing_secrets() {
285+
let mut backend = make_test_backend(ExecVersion::V1);
286+
let (_tx, mut rx) = broadcast::channel(1);
287+
let query_secrets: HashSet<String> =
288+
["fake_secret_900"].into_iter().map(String::from).collect();
289+
let fetched_keys = backend.retrieve(query_secrets.clone(), &mut rx).await;
290+
assert_eq!(format!("{}", fetched_keys.unwrap_err()), "secret for key 'fake_secret_900' was not retrieved: backend does not provide secret key");
291+
}
292+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
Mock secret manager implementation used for testing the secrets.exec backend type.
3+
This program is meant to be exec'ed by a unit test so that the implementation can be tested.
4+
"""
5+
6+
import sys
7+
import json
8+
from typing import Any
9+
10+
11+
class Request:
12+
def __init__(
13+
self,
14+
version: str,
15+
secrets: list[str],
16+
type: str | None = None,
17+
config: dict[str, Any] | None = None,
18+
):
19+
self.version = version
20+
self.secrets = secrets
21+
self.type = type
22+
self.config = config
23+
24+
25+
class Response:
26+
def __init__(self, contents: dict[str, dict[str, str | None]]):
27+
self.contents = contents
28+
29+
30+
def parse_request(req: dict[str, Any]) -> Request:
31+
"""
32+
Validate the request by ensuring the correct keys exist per version, and that the types of the
33+
respective keys are as expected as well
34+
"""
35+
v1_args = set(["version", "secrets"])
36+
v1_1_args = set(["version", "secrets", "type", "config"])
37+
if "version" not in req.keys():
38+
raise RuntimeError("version key missing from request")
39+
version = req["version"]
40+
if version == "1.0":
41+
if v1_args != set(req.keys()):
42+
raise RuntimeError(f"Invalid required keys in 1.0 request: {req.keys()}")
43+
if not isinstance(req["secrets"], list):
44+
raise RuntimeError("key 'secrets' should be a list")
45+
return Request(version, req["secrets"])
46+
elif version == "1.1":
47+
if v1_1_args != set(req.keys()):
48+
raise RuntimeError(f"Invalid required keys in 1.1 request: {req.keys()}")
49+
if not isinstance(req["secrets"], list):
50+
raise RuntimeError("key 'secrets' should be a list")
51+
if not isinstance(req["type"], str):
52+
raise RuntimeError("key 'type' should be a str")
53+
if not isinstance(req["config"], dict):
54+
raise RuntimeError("key 'config' should be a dict")
55+
return Request(version, req["secrets"], req["type"], req["config"])
56+
else:
57+
raise RuntimeError(f"Invalid version detected: {version}")
58+
59+
60+
def handle_request(req: Request) -> Response:
61+
"""
62+
Handle the request by looking up the requested secret with a value that is in a static fake
63+
secret cache below. Any values not contained will return the appropriate error message.
64+
"""
65+
static_fake_secrets_cache = {
66+
"fake_secret_1": "123456",
67+
"fake_secret_2": "123457",
68+
"fake_secret_3": "123458",
69+
"fake_secret_4": "123459",
70+
"fake_secret_5": "123460",
71+
}
72+
supported_fake_backends = ["file.json"]
73+
if req.version == "1.1":
74+
if req.type not in supported_fake_backends:
75+
raise RuntimeError(f"Requested backend: {req.type} not supported")
76+
if req.config is not None and "file_path" not in req.config:
77+
raise RuntimeError("File backend option file_path must be supplied")
78+
79+
def get_secret(fake_secret_name: str) -> dict[str, str | None]:
80+
if fake_secret_name in static_fake_secrets_cache:
81+
return {"value": static_fake_secrets_cache[fake_secret_name], "error": None}
82+
else:
83+
return {"value": None, "error": "backend does not provide secret key"}
84+
85+
return Response(dict([(s, get_secret(s)) for s in req.secrets]))
86+
87+
88+
def main():
89+
data = sys.stdin.buffer.read()
90+
req = json.loads(data)
91+
req = parse_request(req)
92+
resp = handle_request(req)
93+
sys.stdout.write(json.dumps(resp.contents))
94+
95+
96+
if __name__ == "__main__":
97+
try:
98+
main()
99+
except Exception as e:
100+
sys.stderr.write(str(e))
101+
sys.exit(1)

0 commit comments

Comments
 (0)