diff --git a/Cargo.lock b/Cargo.lock index dc62daba..f0ce4451 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -273,9 +273,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.101.0" +version = "1.102.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab41ad64e4051ecabeea802d6a17845a91e83287e1dd249e6963ea1ba78c428a" +checksum = "0fc35b7a14cabdad13795fbbbd26d5ddec0882c01492ceedf2af575aad5f37dd" dependencies = [ "aws-credential-types", "aws-runtime", @@ -307,15 +307,20 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "bytes", + "crypto-bigint 0.5.5", "form_urlencoded", "hex", "hmac", "http 0.2.12", "http 1.4.0", + "p256", "percent-encoding", + "ring", "sha2", + "subtle", "time", "tracing", + "zeroize", ] [[package]] @@ -448,9 +453,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.10.3" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "028999056d2d2fd58a697232f9eec4a643cf73a71cf327690a7edad1d2af2110" +checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -474,11 +479,12 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.11.6" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ab3c9c29791ba4ba02b780a3049e21ec63dabda09268b175272c3733a79e6" +checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" dependencies = [ "aws-smithy-async", + "aws-smithy-runtime-api-macros", "aws-smithy-types", "bytes", "http 0.2.12", @@ -489,6 +495,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "aws-smithy-types" version = "1.4.7" @@ -593,6 +610,12 @@ dependencies = [ "tokio-test", ] +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + [[package]] name = "base64" version = "0.21.7" @@ -615,6 +638,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.11.1" @@ -805,6 +834,12 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-random" version = "0.1.18" @@ -933,6 +968,28 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1018,6 +1075,16 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1136,12 +1203,44 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1186,6 +1285,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1318,6 +1427,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.27" @@ -2111,6 +2231,17 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2201,6 +2332,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + [[package]] name = "plotters" version = "0.3.7" @@ -2375,7 +2516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -2385,7 +2526,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -2519,6 +2669,17 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + [[package]] name = "ring" version = "0.17.14" @@ -2734,6 +2895,20 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -2935,6 +3110,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -2973,6 +3158,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3160,9 +3355,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -3492,9 +3687,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "js-sys", "wasm-bindgen", @@ -3545,11 +3740,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -3558,7 +3753,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -3672,9 +3867,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -3943,6 +4138,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" diff --git a/aws_secretsmanager_caching/src/lib.rs b/aws_secretsmanager_caching/src/lib.rs index 56785c56..03a16927 100644 --- a/aws_secretsmanager_caching/src/lib.rs +++ b/aws_secretsmanager_caching/src/lib.rs @@ -430,37 +430,50 @@ impl SecretsManagerCachingClient { counter.load(Ordering::Relaxed) } } - #[cfg(test)] mod tests { + use aws_sdk_secretsmanager::{ + config::http::HttpResponse, + error::ProvideErrorMetadata, + operation::{ + describe_secret::DescribeSecretOutput, + get_secret_value::{GetSecretValueError, GetSecretValueOutput}, + }, + types::error::ResourceNotFoundException, + }; + use aws_smithy_mocks::{mock, mock_client, RuleMode}; + use aws_smithy_types::body::SdkBody; use tokio::time::sleep; use super::*; - use aws_smithy_runtime_api::client::http::SharedHttpClient; - - fn fake_client( - ttl: Option, - ignore_transient_errors: bool, - http_client: Option, - endpoint_url: Option, - ) -> SecretsManagerCachingClient { - SecretsManagerCachingClient::new( - asm_mock::def_fake_client(http_client, endpoint_url), - NonZeroUsize::new(1000).unwrap(), - match ttl { - Some(ttl) => ttl, - None => Duration::from_secs(1000), - }, - ignore_transient_errors, - ) - .expect("client should create") - } + use aws_smithy_runtime_api::{client::result::SdkError, http::StatusCode}; #[tokio::test] async fn test_get_secret_value() { - let client = fake_client(None, false, None, None); let secret_id = "test_secret"; + let arn = "arn"; + + let gsv = mock!(aws_sdk_secretsmanager::Client::get_secret_value) + .match_requests(|req| req.secret_id() == Some(secret_id)) + .then_output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("hunter2") + .version_stages("AWSCURRENT") + .build() + }); + + let asm_mock = mock_client!(aws_sdk_secretsmanager, [&gsv]); + + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::from_secs(1000), + true, + ) + .unwrap(); let response = client .get_secret_value(secret_id, None, None, false) @@ -469,53 +482,87 @@ mod tests { assert_eq!(response.name, Some(secret_id.to_string())); assert_eq!(response.secret_string, Some("hunter2".to_string())); - assert_eq!( - response.arn, - Some( - asm_mock::FAKE_ARN - .replace("{{name}}", secret_id) - .to_string() - ) - ); + assert_eq!(response.arn, Some(arn.into())); assert_eq!( response.version_stages, Some(vec!["AWSCURRENT".to_string()]) ); + assert_eq!(gsv.num_calls(), 1) } #[tokio::test] async fn test_get_secret_value_version_id() { - let client = fake_client(None, false, None, None); let secret_id = "test_secret"; let version_id = "test_version"; + let arn = "arn"; + + let gsv = mock!(aws_sdk_secretsmanager::Client::get_secret_value) + .match_requests(|req| { + req.secret_id() == Some(secret_id) && req.version_id() == Some(version_id) + }) + .then_output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("hunter2") + .version_id(version_id) + .version_stages("AWSCURRENT") + .build() + }); + + let asm_mock = mock_client!(aws_sdk_secretsmanager, [&gsv]); + + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::from_secs(1000), + true, + ) + .unwrap(); let response = client .get_secret_value(secret_id, Some(version_id), None, false) .await .unwrap(); - assert_eq!(response.name, Some(secret_id.to_string())); assert_eq!(response.secret_string, Some("hunter2".to_string())); assert_eq!(response.version_id, Some(version_id.to_string())); - assert_eq!( - response.arn, - Some( - asm_mock::FAKE_ARN - .replace("{{name}}", secret_id) - .to_string() - ) - ); + assert_eq!(response.arn, Some(arn.into())); assert_eq!( response.version_stages, Some(vec!["AWSCURRENT".to_string()]) ); + assert_eq!(gsv.num_calls(), 1) } #[tokio::test] async fn test_get_secret_value_version_stage() { - let client = fake_client(None, false, None, None); let secret_id = "test_secret"; let stage_label = "STAGEHERE"; + let arn = "arn"; + + let gsv = mock!(aws_sdk_secretsmanager::Client::get_secret_value) + .match_requests(|req| { + req.secret_id() == Some(secret_id) && req.version_stage() == Some(stage_label) + }) + .then_output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("hunter2") + .version_stages(stage_label) + .build() + }); + + let asm_mock = mock_client!(aws_sdk_secretsmanager, [&gsv]); + + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::from_secs(1000), + true, + ) + .unwrap(); let response = client .get_secret_value(secret_id, None, Some(stage_label), false) @@ -524,47 +571,97 @@ mod tests { assert_eq!(response.name, Some(secret_id.to_string())); assert_eq!(response.secret_string, Some("hunter2".to_string())); - assert_eq!( - response.arn, - Some( - asm_mock::FAKE_ARN - .replace("{{name}}", secret_id) - .to_string() - ) - ); + assert_eq!(response.arn, Some(arn.into())); assert_eq!(response.version_stages, Some(vec![stage_label.to_string()])); + assert_eq!(gsv.num_calls(), 1) } #[tokio::test] async fn test_get_secret_value_version_id_and_stage() { - let client = fake_client(None, false, None, None); let secret_id = "test_secret"; let version_id = "test_version"; let stage_label = "STAGEHERE"; + let arn = "arn"; + + let gsv = mock!(aws_sdk_secretsmanager::Client::get_secret_value) + .match_requests(|req| { + req.secret_id() == Some(secret_id) + && req.version_stage() == Some(stage_label) + && req.version_id() == Some(version_id) + }) + .then_output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("hunter2") + .version_stages(stage_label) + .version_id(version_id) + .build() + }); + + let asm_mock = mock_client!(aws_sdk_secretsmanager, [&gsv]); + + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::from_secs(1000), + true, + ) + .unwrap(); let response = client .get_secret_value(secret_id, Some(version_id), Some(stage_label), false) .await .unwrap(); - assert_eq!(response.name, Some(secret_id.to_string())); assert_eq!(response.secret_string, Some("hunter2".to_string())); assert_eq!(response.version_id, Some(version_id.to_string())); - assert_eq!( - response.arn, - Some( - asm_mock::FAKE_ARN - .replace("{{name}}", secret_id) - .to_string() - ) - ); + assert_eq!(response.arn, Some(arn.into())); assert_eq!(response.version_stages, Some(vec![stage_label.to_string()])); + assert_eq!(gsv.num_calls(), 1) } #[tokio::test] async fn test_get_cache_expired() { - let client = fake_client(Some(Duration::from_secs(0)), false, None, None); let secret_id = "test_secret"; + let version_id = "version_id"; + let version_stage = "AWSCURRENT"; + let arn = "arn"; + + let gsv = mock!(aws_sdk_secretsmanager::Client::get_secret_value) + .match_requests(|req| req.secret_id() == Some(secret_id)) + .then_output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("hunter2") + .version_id(version_id) + .version_stages(version_stage) + .build() + }); + + let describe_secret = + mock!(aws_sdk_secretsmanager::Client::describe_secret).then_output(move || { + // Don't serve the same value + DescribeSecretOutput::builder() + .name(secret_id) + .version_ids_to_stages("different_version_id", vec![version_stage.into()]) + .build() + }); + + let asm_mock = mock_client!( + aws_sdk_secretsmanager, + RuleMode::MatchAny, + [&gsv, &describe_secret] + ); + + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::from_secs(0), + true, + ) + .unwrap(); // Run through this twice to test the cache expiration for i in 0..2 { @@ -575,14 +672,7 @@ mod tests { assert_eq!(response.name, Some(secret_id.to_string())); assert_eq!(response.secret_string, Some("hunter2".to_string())); - assert_eq!( - response.arn, - Some( - asm_mock::FAKE_ARN - .replace("{{name}}", secret_id) - .to_string() - ) - ); + assert_eq!(response.arn, Some(arn.into())); assert_eq!( response.version_stages, Some(vec!["AWSCURRENT".to_string()]) @@ -592,229 +682,345 @@ mod tests { sleep(Duration::from_millis(50)).await; } } + + assert_eq!(gsv.num_calls(), 2) } #[tokio::test] - #[should_panic] async fn test_get_secret_value_kms_access_denied() { - let client = fake_client(None, false, None, None); + let gsv = + mock!(aws_sdk_secretsmanager::Client::get_secret_value).then_http_response(|| { + HttpResponse::new( + StatusCode::try_from(400).unwrap(), + SdkBody::from( + r##"{ + "__type":"AccessDeniedException", + "message":"Access to KMS is not allowed" + }"##, + ), + ) + }); + + let asm_mock = mock_client!(aws_sdk_secretsmanager, &[gsv]); + + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::from_secs(1000), + true, + ) + .unwrap(); let secret_id = "KMSACCESSDENIEDabcdef"; - client - .get_secret_value(secret_id, None, None, false) - .await - .unwrap(); + let error = match client.get_secret_value(secret_id, None, None, false).await { + Ok(_) => panic!(), + Err(e) => e + .downcast::>() + .expect("should be an SdkError") + .into_service_error(), + }; + + assert_eq!(error.code(), Some("AccessDeniedException")); + assert_eq!(error.message(), Some("Access to KMS is not allowed")); } #[tokio::test] - #[should_panic] async fn test_get_secret_value_resource_not_found() { - let client = fake_client(None, false, None, None); + let gsv = mock!(aws_sdk_secretsmanager::Client::get_secret_value).then_error(|| { + GetSecretValueError::ResourceNotFoundException( + ResourceNotFoundException::builder() + .message("Secrets Manager can't find the specified secret.") + .build(), + ) + }); + + let asm_mock = mock_client!(aws_sdk_secretsmanager, &[gsv]); + + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::from_secs(1000), + true, + ) + .unwrap(); + let secret_id = "NOTFOUNDfasefasef"; - client - .get_secret_value(secret_id, None, None, false) - .await - .unwrap(); + match client.get_secret_value(secret_id, None, None, false).await { + Ok(_) => panic!(), + Err(e) => assert!(e + .downcast::>() + .unwrap() + .into_service_error() + .is_resource_not_found_exception()), + }; } - #[tokio::test] - async fn test_is_current_default_succeeds() { - let client = fake_client(Some(Duration::from_secs(0)), false, None, None); + /// Helper for is_current tests: builds a mock client with GSV + DescribeSecret + /// that returns matching version data, then asserts two fetches (with a TTL=0 + /// expiry sleep in between) result in only 1 GSV call and 1 Describe call. + async fn assert_is_current_fast_refreshes( + query_version_id: Option<&'static str>, + query_version_stage: Option<&'static str>, + ) { let secret_id = "test_secret"; + let version_id = "version_id"; + let version_stage = query_version_stage.unwrap_or("AWSCURRENT"); + let arn = "arn"; + + let gsv = mock!(aws_sdk_secretsmanager::Client::get_secret_value) + .match_requests(move |req| { + req.secret_id() == Some(secret_id) + && req.version_id() == query_version_id + && req.version_stage() == query_version_stage + }) + .then_output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("hunter2") + .version_id(version_id) + .version_stages(version_stage) + .build() + }); + + let describe_secret = + mock!(aws_sdk_secretsmanager::Client::describe_secret).then_output(move || { + // Cache is current. We fast-refresh + DescribeSecretOutput::builder() + .name(secret_id) + .version_ids_to_stages(version_id, vec![version_stage.into()]) + .build() + }); + + let asm_mock = mock_client!( + aws_sdk_secretsmanager, + RuleMode::MatchAny, + [&gsv, &describe_secret] + ); - let res1 = client - .get_secret_value(secret_id, None, None, false) - .await - .unwrap(); + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::from_secs(0), + true, + ) + .unwrap(); - let res2 = client - .get_secret_value(secret_id, None, None, false) - .await - .unwrap(); + // Run through this twice to test the cache expiration + for i in 0..2 { + let response = client + .get_secret_value(secret_id, query_version_id, query_version_stage, false) + .await + .unwrap(); - assert_eq!(res1, res2) + assert_eq!(response.name, Some(secret_id.to_string())); + assert_eq!(response.secret_string, Some("hunter2".to_string())); + assert_eq!(response.arn, Some(arn.into())); + assert_eq!(response.version_stages, Some(vec![version_stage.into()])); + if i == 0 { + sleep(Duration::from_millis(50)).await; + } + } + + assert_eq!(gsv.num_calls(), 1); + assert_eq!(describe_secret.num_calls(), 1); } #[tokio::test] - async fn test_is_current_version_id_succeeds() { - let client = fake_client(Some(Duration::from_secs(0)), false, None, None); - let secret_id = "test_secret"; - let version_id = Some("test_version"); - - let res1 = client - .get_secret_value(secret_id, version_id, None, false) - .await - .unwrap(); - - let res2 = client - .get_secret_value(secret_id, version_id, None, false) - .await - .unwrap(); + async fn test_get_cache_is_current_fast_refreshes() { + assert_is_current_fast_refreshes(None, None).await; + } - assert_eq!(res1, res2) + #[tokio::test] + async fn test_is_current_version_id_succeeds() { + assert_is_current_fast_refreshes(Some("version_id"), None).await; } #[tokio::test] async fn test_is_current_version_stage_succeeds() { - let client = fake_client(Some(Duration::from_secs(0)), false, None, None); - let secret_id = "test_secret"; - let version_stage = Some("VERSIONSTAGE"); - - let res1 = client - .get_secret_value(secret_id, None, version_stage, false) - .await - .unwrap(); - - let res2 = client - .get_secret_value(secret_id, None, version_stage, false) - .await - .unwrap(); + assert_is_current_fast_refreshes(None, Some("VERSIONSTAGE")).await; + } - assert_eq!(res1, res2) + #[tokio::test] + async fn test_is_current_both_version_id_and_version_stage_succeed() { + assert_is_current_fast_refreshes(Some("version_id"), Some("VERSIONSTAGE")).await; } #[tokio::test] - async fn test_is_current_both_version_id_and_version_stage_succeeds() { - let client = fake_client(Some(Duration::from_secs(0)), false, None, None); + async fn test_is_current_describe_access_denied_fails() { let secret_id = "test_secret"; - let version_id = Some("test_version"); - let version_stage = Some("VERSIONSTAGE"); + let version_id = "version_id"; + let version_stage = "VERSIONSTAGE"; + let arn = "arn"; + let secret_string = "hunter2"; + + let gsv = mock!(aws_sdk_secretsmanager::Client::get_secret_value) + .match_requests(|req| { + req.secret_id() == Some(secret_id) + && req.version_stage() == Some(version_stage) + && req.version_id() == Some(version_id) + }) + .then_output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string(secret_string) + .version_id(version_id) + .version_stages(version_stage) + .build() + }); + + let describe_secret = + mock!(aws_sdk_secretsmanager::Client::describe_secret).then_http_response(|| { + HttpResponse::new( + StatusCode::try_from(400).unwrap(), + SdkBody::from( + r##"{ + "__type":"AccessDeniedException", + "message":"is not authorized to perform: secretsmanager:DescribeSecret on resource: XXXXXXXX" + }"##, + ), + ) + }); - let res1 = client - .get_secret_value(secret_id, version_id, version_stage, false) - .await - .unwrap(); + let asm_mock = mock_client!( + aws_sdk_secretsmanager, + RuleMode::MatchAny, + [&gsv, &describe_secret] + ); - let res2 = client - .get_secret_value(secret_id, version_id, version_stage, false) + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::from_secs(0), + true, + ) + .unwrap(); + + // Run through this twice to test the cache expiration + let response = client + .get_secret_value(secret_id, Some(version_id), Some(version_stage), false) .await .unwrap(); - assert_eq!(res1, res2) - } - - #[tokio::test] - async fn test_is_current_describe_access_denied_fails() { - let client = fake_client(Some(Duration::from_secs(0)), false, None, None); - let secret_id = "DESCRIBEACCESSDENIED_test_secret"; - let version_id = Some("test_version"); + assert_eq!(response.name, Some(secret_id.to_string())); + assert_eq!(response.secret_string, Some(secret_string.to_string())); + assert_eq!(response.arn, Some(arn.into())); + assert_eq!( + response.version_stages, + Some(vec![version_stage.to_string()]) + ); + // let the entry expire + sleep(Duration::from_millis(50)).await; - client - .get_secret_value(secret_id, version_id, None, false) + if client + .get_secret_value(secret_id, Some(version_id), Some(version_stage), false) .await - .unwrap(); - - if (client - .get_secret_value(secret_id, version_id, None, false) - .await) .is_ok() { panic!("Expected failure") } - } - - #[tokio::test] - async fn test_is_current_describe_timeout_error_succeeds() { - use asm_mock::GSV_BODY; - use aws_smithy_runtime::client::http::test_util::wire::{ReplayedEvent, WireMockServer}; - - let mock = WireMockServer::start(vec![ - ReplayedEvent::with_body(GSV_BODY), - ReplayedEvent::Timeout, - ]) - .await; - let client = fake_client( - Some(Duration::from_secs(0)), - true, - Some(mock.http_client()), - Some(mock.endpoint_url()), - ); - let secret_id = "DESCRIBETIMEOUT_test_secret"; - let version_id = Some("test_version"); - - let res1 = client - .get_secret_value(secret_id, version_id, None, false) - .await - .unwrap(); - - let res2 = client - .get_secret_value(secret_id, version_id, None, false) - .await - .unwrap(); - mock.shutdown(); - - assert_eq!(res1, res2) + assert_eq!(gsv.num_calls(), 1) } #[tokio::test] async fn test_is_current_describe_service_error_succeeds() { - let client = fake_client(Some(Duration::from_secs(0)), true, None, None); let secret_id = "DESCRIBESERVICEERROR_test_secret"; - let version_id = Some("test_version"); - let version_stage = Some("VERSIONSTAGE"); - - let res1 = client - .get_secret_value(secret_id, version_id, version_stage, false) - .await - .unwrap(); - - let res2 = client - .get_secret_value(secret_id, version_id, version_stage, false) - .await - .unwrap(); - - assert_eq!(res1, res2) - } + let version_id = "test_version"; + let version_stage = "VERSIONSTAGE"; + let arn = "arn"; + + let gsv = mock!(aws_sdk_secretsmanager::Client::get_secret_value) + .match_requests(|req| { + req.secret_id() == Some(secret_id) + && req.version_stage() == Some(version_stage) + && req.version_id() == Some(version_id) + }) + .then_output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("hunter2") + .version_id(version_id) + .version_stages(version_stage) + .build() + }); + + let describe_secret = mock!(aws_sdk_secretsmanager::Client::describe_secret) + .then_http_response(|| { + HttpResponse::new( + StatusCode::try_from(500).unwrap(), + SdkBody::from( + r##"{ + "__type": "InternalServiceError", + "message": "Internal service error" + }"##, + ), + ) + }); - #[tokio::test] - async fn test_is_current_gsv_timeout_error_succeeds() { - use asm_mock::DESC_BODY; - use asm_mock::GSV_BODY; - use aws_smithy_runtime::client::http::test_util::wire::{ReplayedEvent, WireMockServer}; - - let mock = WireMockServer::start(vec![ - ReplayedEvent::with_body( - GSV_BODY - .replace("{{version}}", "old_version") - .replace("{{label}}", "AWSCURRENT"), - ), - ReplayedEvent::with_body( - DESC_BODY - .replace("{{version}}", "new_version") - .replace("{{label}}", "AWSCURRENT"), - ), - ReplayedEvent::Timeout, - ]) - .await; - let client = fake_client( - Some(Duration::from_secs(0)), - true, - Some(mock.http_client()), - Some(mock.endpoint_url()), + let asm_mock = mock_client!( + aws_sdk_secretsmanager, + RuleMode::MatchAny, + [&gsv, &describe_secret] ); - let secret_id = "GSVTIMEOUT_test_secret"; + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::ZERO, + true, + ) + .unwrap(); let res1 = client - .get_secret_value(secret_id, None, None, false) + .get_secret_value(secret_id, Some(version_id), Some(version_stage), false) .await .unwrap(); let res2 = client - .get_secret_value(secret_id, None, None, false) + .get_secret_value(secret_id, Some(version_id), Some(version_stage), false) .await .unwrap(); - mock.shutdown(); - assert_eq!(res1, res2) } #[tokio::test] async fn test_get_secret_value_refresh_now_true() { - let client = fake_client(Some(Duration::from_secs(30)), false, None, None); let secret_id = "REFRESHNOW_test_secret"; + let arn = "arn"; + + let gsv = mock!(aws_sdk_secretsmanager::Client::get_secret_value) + .match_requests(|req| req.secret_id() == Some(secret_id)) + .sequence() + .output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("hunter2") + .version_stages("AWSCURRENT") + .build() + }) + .output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("some other string") + .version_stages("AWSCURRENT") + .build() + }) + .build(); + + let asm_mock = mock_client!(aws_sdk_secretsmanager, [&gsv]); + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::from_secs(30), + true, + ) + .unwrap(); let response1 = client .get_secret_value(secret_id, None, None, false) @@ -822,35 +1028,57 @@ mod tests { .unwrap(); assert_eq!(response1.name, Some(secret_id.to_string())); - assert_eq!( - response1.arn, - Some( - asm_mock::FAKE_ARN - .replace("{{name}}", secret_id) - .to_string() - ) - ); + assert_eq!(response1.arn, Some(arn.into())); assert_eq!( response1.version_stages, Some(vec!["AWSCURRENT".to_string()]) ); - sleep(Duration::from_millis(1)).await; - let response2 = client .get_secret_value(secret_id, None, None, true) .await .unwrap(); - assert_ne!(response1.secret_string, response2.secret_string); assert_eq!(response1.arn, response2.arn); assert_eq!(response1.version_stages, response2.version_stages); + + assert_eq!(gsv.num_calls(), 2) } #[tokio::test] async fn test_get_secret_value_refresh_now_false() { - let client = fake_client(Some(Duration::from_secs(30)), false, None, None); let secret_id = "REFRESHNOW_test_secret"; + let arn = "arn"; + + let gsv = mock!(aws_sdk_secretsmanager::Client::get_secret_value) + .match_requests(|req| req.secret_id() == Some(secret_id)) + .sequence() + .output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("hunter2") + .version_stages("AWSCURRENT") + .build() + }) + .output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("some other string") + .version_stages("AWSCURRENT") + .build() + }) + .build(); + + let asm_mock = mock_client!(aws_sdk_secretsmanager, [&gsv]); + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::from_secs(30), + true, + ) + .unwrap(); let response1 = client .get_secret_value(secret_id, None, None, false) @@ -858,69 +1086,88 @@ mod tests { .unwrap(); assert_eq!(response1.name, Some(secret_id.to_string())); - assert_eq!( - response1.arn, - Some( - asm_mock::FAKE_ARN - .replace("{{name}}", secret_id) - .to_string() - ) - ); + assert_eq!(response1.arn, Some(arn.into())); assert_eq!( response1.version_stages, Some(vec!["AWSCURRENT".to_string()]) ); - sleep(Duration::from_millis(1)).await; - let response2 = client .get_secret_value(secret_id, None, None, false) .await .unwrap(); assert_eq!(response1, response2); + + assert_eq!(gsv.num_calls(), 1) } #[tokio::test] async fn test_get_secret_value_version_id_and_stage_refresh_now() { - let client = fake_client(Some(Duration::from_secs(30)), false, None, None); let secret_id = "REFRESHNOW_test_secret"; let version_id = "test_version"; let stage_label = "STAGEHERE"; + let arn = "arn"; + + let gsv = mock!(aws_sdk_secretsmanager::Client::get_secret_value) + .match_requests(|req| req.secret_id() == Some(secret_id)) + .sequence() + .output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("hunter2") + .version_stages("AWSCURRENT") + .build() + }) + .output(move || { + GetSecretValueOutput::builder() + .name(secret_id) + .arn(arn) + .secret_string("some other string") + .version_stages("AWSCURRENT") + .build() + }) + .build(); + + let asm_mock = mock_client!(aws_sdk_secretsmanager, [&gsv]); + let client = SecretsManagerCachingClient::new( + asm_mock, + NonZeroUsize::new(1000).unwrap(), + Duration::from_secs(30), + true, + ) + .unwrap(); let response1 = client .get_secret_value(secret_id, Some(version_id), Some(stage_label), false) .await .unwrap(); - sleep(Duration::from_millis(1)).await; - let response2 = client .get_secret_value(secret_id, Some(version_id), Some(stage_label), true) .await .unwrap(); - assert_ne!(response1.secret_string, response2.secret_string); assert_eq!(response1.arn, response2.arn); assert_eq!(response1.version_stages, response2.version_stages); + + assert_eq!(gsv.num_calls(), 2); } - mod asm_mock { + mod wire_tests { use aws_sdk_secretsmanager as secretsmanager; - use aws_smithy_runtime::client::http::test_util::infallible_client_fn; + + use aws_smithy_runtime::client::http::test_util::wire::WireMockServer; use aws_smithy_runtime_api::client::http::SharedHttpClient; - use aws_smithy_types::body::SdkBody; + use aws_smithy_types::timeout::TimeoutConfig; - use http::{Request, Response}; + use secretsmanager::config::BehaviorVersion; - use serde_json::Value; - use std::time::{Duration, SystemTime, UNIX_EPOCH}; - pub const FAKE_ARN: &str = - "arn:aws:secretsmanager:us-west-2:123456789012:secret:{{name}}-NhBWsc"; - pub const DEFAULT_VERSION: &str = "5767290c-d089-49ed-b97c-17086f8c9d79"; - pub const DEFAULT_LABEL: &str = "AWSCURRENT"; - pub const DEFAULT_SECRET_STRING: &str = "hunter2"; + use std::{num::NonZeroUsize, time::Duration}; + + use crate::SecretsManagerCachingClient; // Template GetSecretValue responses for testing pub const GSV_BODY: &str = r###"{ @@ -933,7 +1180,6 @@ mod tests { ], "CreatedDate": 1569534789.046 }"###; - // Template DescribeSecret responses for testing pub const DESC_BODY: &str = r###"{ "ARN": "{{arn}}", @@ -950,83 +1196,10 @@ mod tests { "CreatedDate": 1569534789.046 }"###; - // Template for access denied testing - const KMS_ACCESS_DENIED_BODY: &str = r###"{ - "__type":"AccessDeniedException", - "Message":"Access to KMS is not allowed" - }"###; - - // Template for testing resource not found with DescribeSecret - const NOT_FOUND_EXCEPTION_BODY: &str = r###"{ - "__type":"ResourceNotFoundException", - "message":"Secrets Manager can't find the specified secret." - }"###; - - const SECRETSMANAGER_ACCESS_DENIED_BODY: &str = r###"{ - "__type:"AccessDeniedException", - "Message": "is not authorized to perform: secretsmanager:DescribeSecret on resource: XXXXXXXX" - }"###; - - const SECRETSMANAGER_INTERNAL_SERVICE_ERROR_BODY: &str = r###"{ - "__type:"InternalServiceError", - "Message": "Internal service error" - }"###; - - // Private helper to look at the request and provide the correct response. - fn format_rsp(req: Request) -> (u16, String) { - let (parts, body) = req.into_parts(); - - let req_map: serde_json::Map = - serde_json::from_slice(body.bytes().unwrap()).unwrap(); - let version = req_map - .get("VersionId") - .map_or(DEFAULT_VERSION, |x| x.as_str().unwrap()); - let label = req_map - .get("VersionStage") - .map_or(DEFAULT_LABEL, |x| x.as_str().unwrap()); - let name = req_map.get("SecretId").unwrap().as_str().unwrap(); // Does not handle full ARN case. - - let secret_string = match name { - secret if secret.starts_with("REFRESHNOW") => SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() - .to_string(), - _ => DEFAULT_SECRET_STRING.to_string(), - }; - - let (code, template) = match parts.headers["x-amz-target"].to_str().unwrap() { - "secretsmanager.GetSecretValue" if name.starts_with("KMSACCESSDENIED") => { - (400, KMS_ACCESS_DENIED_BODY) - } - "secretsmanager.GetSecretValue" if name.starts_with("NOTFOUND") => { - (400, NOT_FOUND_EXCEPTION_BODY) - } - "secretsmanager.GetSecretValue" => (200, GSV_BODY), - "secretsmanager.DescribeSecret" if name.contains("DESCRIBEACCESSDENIED") => { - (400, SECRETSMANAGER_ACCESS_DENIED_BODY) - } - "secretsmanager.DescribeSecret" if name.contains("DESCRIBESERVICEERROR") => { - (500, SECRETSMANAGER_INTERNAL_SERVICE_ERROR_BODY) - } - "secretsmanager.DescribeSecret" => (200, DESC_BODY), - _ => panic!("Unknown operation"), - }; - - // Fill in the template and return the response. - let rsp = template - .replace("{{arn}}", FAKE_ARN) - .replace("{{name}}", name) - .replace("{{version}}", version) - .replace("{{secret}}", &secret_string) - .replace("{{label}}", label); - (code, rsp) - } - // Test client that stubs off network call and provides a canned response. pub fn def_fake_client( - http_client: Option, - endpoint_url: Option, + http_client: SharedHttpClient, + endpoint_url: String, ) -> secretsmanager::Client { use aws_smithy_types::retry::RetryConfig; @@ -1037,8 +1210,7 @@ mod tests { None, "", ); - - let mut config_builder = secretsmanager::Config::builder() + let config_builder = secretsmanager::Config::builder() .behavior_version(BehaviorVersion::latest()) .credentials_provider(fake_creds) .region(secretsmanager::config::Region::new("us-west-2")) @@ -1048,22 +1220,97 @@ mod tests { .build(), ) .retry_config(RetryConfig::disabled()) - .http_client(match http_client { - Some(custom_client) => custom_client, - None => infallible_client_fn(|_req| { - let (code, rsp) = format_rsp(_req); - Response::builder() - .status(code) - .body(SdkBody::from(rsp)) - .unwrap() - }), - }); - config_builder = match endpoint_url { - Some(endpoint_url) => config_builder.endpoint_url(endpoint_url), - None => config_builder, - }; + .http_client(http_client) + .endpoint_url(endpoint_url); secretsmanager::Client::from_conf(config_builder.build()) } + + fn fake_client( + ttl: Option, + ignore_transient_errors: bool, + wire_server: &WireMockServer, + ) -> SecretsManagerCachingClient { + SecretsManagerCachingClient::new( + def_fake_client(wire_server.http_client(), wire_server.endpoint_url()), + NonZeroUsize::new(1000).unwrap(), + match ttl { + Some(ttl) => ttl, + None => Duration::from_secs(1000), + }, + ignore_transient_errors, + ) + .expect("client should create") + } + + #[tokio::test] + async fn test_is_current_gsv_timeout_error_succeeds() { + use aws_smithy_runtime::client::http::test_util::wire::{ + ReplayedEvent, WireMockServer, + }; + + let mock = WireMockServer::start(vec![ + ReplayedEvent::with_body( + GSV_BODY + .replace("{{version}}", "old_version") + .replace("{{label}}", "AWSCURRENT"), + ), + ReplayedEvent::with_body( + DESC_BODY + .replace("{{version}}", "new_version") + .replace("{{label}}", "AWSCURRENT"), + ), + ReplayedEvent::Timeout, + ]) + .await; + + let client = fake_client(Some(Duration::from_secs(0)), true, &mock); + + let secret_id = "GSVTIMEOUT_test_secret"; + + let res1 = client + .get_secret_value(secret_id, None, None, false) + .await + .unwrap(); + + let res2 = client + .get_secret_value(secret_id, None, None, false) + .await + .unwrap(); + + mock.shutdown(); + + assert_eq!(res1, res2) + } + + #[tokio::test] + async fn test_is_current_describe_timeout_error_succeeds() { + use aws_smithy_runtime::client::http::test_util::wire::{ + ReplayedEvent, WireMockServer, + }; + + let mock = WireMockServer::start(vec![ + ReplayedEvent::with_body(GSV_BODY), + ReplayedEvent::Timeout, + ]) + .await; + let client = fake_client(Some(Duration::from_secs(0)), true, &mock); + let secret_id = "DESCRIBETIMEOUT_test_secret"; + let version_id = Some("test_version"); + + let res1 = client + .get_secret_value(secret_id, version_id, None, false) + .await + .unwrap(); + + let res2 = client + .get_secret_value(secret_id, version_id, None, false) + .await + .unwrap(); + + mock.shutdown(); + + assert_eq!(res1, res2) + } } }