Skip to content

Commit 81f7d43

Browse files
authored
Support random number generation for recordings (Azure#2148)
* Support random number generation for recordings Resolves Azure#2143 * Resolve PR feedback
1 parent 79eddaa commit 81f7d43

File tree

7 files changed

+162
-5
lines changed

7 files changed

+162
-5
lines changed

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ proc-macro2 = "1.0.86"
9595
quick-xml = { version = "0.31", features = ["serialize", "serde-types"] }
9696
quote = "1.0.37"
9797
rand = "0.8"
98+
rand_chacha = "0.3"
9899
reqwest = { version = "0.12", features = [
99100
"json",
100101
"stream",

eng/dict/crates.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ paste
1919
pin-project
2020
quick-xml
2121
rand
22+
rand_chacha
2223
reqwest
2324
rustc_version
2425
serde
@@ -33,4 +34,4 @@ tracing
3334
tracing-subscriber
3435
tz-rs
3536
url
36-
uuid
37+
uuid

sdk/core/azure_core_test/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ azure_core = { workspace = true, features = ["test"] }
2424
azure_core_test_macros.workspace = true
2525
azure_identity.workspace = true
2626
futures.workspace = true
27+
rand.workspace = true
28+
rand_chacha.workspace = true
2729
serde.workspace = true
2830
serde_json.workspace = true
2931
tracing.workspace = true
@@ -44,6 +46,8 @@ tokio = { workspace = true, features = [
4446
] }
4547

4648
[dev-dependencies]
49+
# Crate used in README.md example.
50+
azure_security_keyvault_secrets = { path = "../../keyvault/azure_security_keyvault_secrets" }
4751
clap.workspace = true
4852
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
4953

sdk/core/azure_core_test/README.md

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,26 @@ The types and functions in this crate help test client libraries built on `azure
77
To test client methods using our [Test Proxy] or run against live resources, you can attribute asynchronous tests
88
using the `#[recorded::test]` attribute:
99

10-
```rust
10+
```rust no_run
1111
use azure_core::Result;
1212
use azure_core_test::{recorded, TestContext};
13+
use azure_security_keyvault_secrets::{SecretClient, SecretClientOptions};
1314

1415
#[recorded::test]
1516
async fn get_secret(ctx: TestContext) -> Result<()> {
17+
let recording = ctx.recording();
18+
19+
// Instrument your client options to hook up the test-proxy pipeline policy.
20+
let mut options = SecretClientOptions::default();
21+
recording.instrument(&mut options.client_options);
22+
23+
// Get variables, credentials, and pass the instrumented client options to the client to begin.
24+
let client = SecretClient::new(
25+
recording.var("AZURE_KEYVAULT_URL", None).as_str(),
26+
recording.credential(),
27+
Some(options),
28+
)?;
29+
1630
todo!()
1731
}
1832
```
@@ -24,4 +38,61 @@ and provides other information to test functions that may be useful.
2438

2539
These tests must also return a `std::result::Result<T, E>`, which can be redefined e.g., `azure_core::Result<T>`.
2640

41+
### Recording features
42+
43+
Besides instrumenting your client and getting credentials - real credentials when running live or recording,
44+
but mock credentials when playing back - there are a number of other helpful features on the `Recording` object returned above:
45+
46+
* `add_sanitizer` will add custom sanitizers. There are many pre-configured by the [Test Proxy] as well.
47+
* `remove_sanitizers` will remove named sanitizers, like `AZSDK3430` that sanitizes all `$..id` fields and may cause playback to fail.
48+
* `add_matcher` adds a custom matcher to match headers, path segments, and or body content.
49+
* `random` gets random data (numbers, arrays, etc.) that is initialized from the OS when running live or recording,
50+
but the seed is saved with the recording and used during play back so that sequential generation of random data is deterministic.
51+
ChaCha20 is used to provide a deterministic, portable sequence of seeded random data.
52+
* `var` gets a required variable with optional `ValueOptions` you can use to sanitize values.
53+
This function will err if the variable is not set in the environment when running live or recording, or available when playing back.
54+
* `var_opt` gets optional variables and will not err in the aforementioned cases.
55+
56+
## Record tests
57+
58+
Like with all our other Azure SDK languages, we use a common system for provisioning resources named [Test Resources].
59+
This uses a `test-resources.json` or `test-resources.bicep` file in the crate or service directory.
60+
61+
When you run this, it will output some environment variables you can set in your shell, or pass on the command line if your shell supports it.
62+
63+
```bash
64+
pwsh ./eng/common/TestResources/New-TestResources.ps1 -ServiceDirectory keyvault
65+
```
66+
67+
In this example, it will output a number of environment variables including `AZURE_KEYVAULT_URL`. We'll pass that in the command line for bash below.
68+
69+
Before you can record, though, you need to make sure you're authenticated. The script above will authenticate you in PowerShell, but the
70+
`AzurePowerShellCredential` is not yet implemented; however, the `AzureCliCredential` is so log in with `az`:
71+
72+
```bash
73+
az login
74+
```
75+
76+
No you can record your tests and, after they pass successfully, check in the test recordings. You need to pass `AZURE_TEST_MODE=record` to record,
77+
along with any other environment variables output by the [Test Resources] scripts.
78+
79+
```bash
80+
AZURE_TEST_MODE=record AZURE_KEYVAULT_URL=https://my-vault.vault.azure.net cargo test -p azure_security_keyvault_secrets
81+
test-proxy push -a sdk/keyvault/assets.json
82+
```
83+
84+
Once your assets are checked in by [Test Proxy], you can commit your local changes and create a PR.
85+
86+
## Playing back tests
87+
88+
To play back tests, just run `cargo test`:
89+
90+
```bash
91+
cargo test
92+
```
93+
94+
If you get errors, they could indicate regressions in your tests or perhaps variables or random data wasn't saved correctly.
95+
Review any data you generate or use not coming from the service.
96+
2797
[Test Proxy]: https://github.com/Azure/azure-sdk-tools/blob/main/tools/test-proxy/Azure.Sdk.Tools.TestProxy/README.md
98+
[Test Resources]: https://github.com/Azure/azure-sdk-tools/blob/main/eng/common/TestResources/README.md

sdk/core/azure_core_test/src/recording.rs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
//! The [`Recording`] and other types used in recorded tests.
55
6+
// cspell:ignore csprng seedable
67
use crate::{
78
proxy::{
89
client::{
@@ -16,19 +17,25 @@ use crate::{
1617
Matcher, MockCredential, Sanitizer,
1718
};
1819
use azure_core::{
20+
base64,
1921
credentials::TokenCredential,
2022
error::ErrorKind,
2123
headers::{AsHeaders, HeaderName, HeaderValue},
2224
test::TestMode,
2325
ClientOptions, Header,
2426
};
2527
use azure_identity::DefaultAzureCredential;
28+
use rand::{
29+
distributions::{Distribution, Standard},
30+
Rng, SeedableRng,
31+
};
32+
use rand_chacha::ChaCha20Rng;
2633
use std::{
2734
borrow::Cow,
2835
cell::OnceCell,
2936
collections::HashMap,
3037
env,
31-
sync::{Arc, RwLock},
38+
sync::{Arc, Mutex, OnceLock, RwLock},
3239
};
3340
use tracing::span::EnteredSpan;
3441

@@ -47,6 +54,7 @@ pub struct Recording {
4754
recording_assets_file: Option<String>,
4855
id: Option<RecordingId>,
4956
variables: RwLock<HashMap<String, Value>>,
57+
rand: OnceLock<Mutex<ChaCha20Rng>>,
5058
}
5159

5260
impl Recording {
@@ -127,6 +135,75 @@ impl Recording {
127135
options.per_try_policies.push(policy);
128136
}
129137

138+
/// Get random data from the OS or recording.
139+
///
140+
/// This will always be the OS cryptographically secure pseudo-random number generator (CSPRNG) when running live.
141+
/// When recording, it will initialize from the OS CSPRNG but save the seed value to the recording file.
142+
/// When playing back, the saved seed value is read from the recording to reproduce the same sequence of random data.
143+
///
144+
/// # Examples
145+
///
146+
/// Generate a symmetric data encryption key (DEK).
147+
///
148+
/// ```no_compile
149+
/// let dek: [u8; 32] = recording.random();
150+
/// ```
151+
///
152+
/// # Panics
153+
///
154+
/// Panics if the recording variables cannot be locked for reading or writing,
155+
/// or if the random seed cannot be encoded or decoded properly.
156+
///
157+
pub fn random<T>(&self) -> T
158+
where
159+
Standard: Distribution<T>,
160+
{
161+
const NAME: &str = "RandomSeed";
162+
163+
// Use ChaCha20 for a deterministic, portable CSPRNG.
164+
let rng = self.rand.get_or_init(|| match self.test_mode {
165+
TestMode::Live => ChaCha20Rng::from_entropy().into(),
166+
TestMode::Playback => {
167+
let variables = self
168+
.variables
169+
.read()
170+
.map_err(read_lock_error)
171+
.unwrap_or_else(|err| panic!("{err}"));
172+
let seed: String = variables
173+
.get(NAME)
174+
.map(Into::into)
175+
.unwrap_or_else(|| panic!("random seed variable not set"));
176+
let seed = base64::decode(seed)
177+
.unwrap_or_else(|err| panic!("failed to decode random seed: {err}"));
178+
let seed = seed
179+
.first_chunk::<32>()
180+
.unwrap_or_else(|| panic!("insufficient random seed variable"));
181+
182+
ChaCha20Rng::from_seed(*seed).into()
183+
}
184+
TestMode::Record => {
185+
let rng = ChaCha20Rng::from_entropy();
186+
let seed = rng.get_seed();
187+
let seed = base64::encode(seed);
188+
189+
let mut variables = self
190+
.variables
191+
.write()
192+
.map_err(write_lock_error)
193+
.unwrap_or_else(|err| panic!("{err}"));
194+
variables.insert(NAME.to_string(), Value::from(Some(seed), None));
195+
196+
rng.into()
197+
}
198+
});
199+
200+
let Ok(mut rng) = rng.lock() else {
201+
panic!("failed to lock RNG");
202+
};
203+
204+
rng.gen()
205+
}
206+
130207
/// Removes the list of sanitizers from the recording.
131208
///
132209
/// You can find a list of default sanitizers in [source code](https://github.com/Azure/azure-sdk-tools/blob/main/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/SanitizerDictionary.cs).
@@ -229,6 +306,7 @@ impl Recording {
229306
recording_assets_file,
230307
id: None,
231308
variables: RwLock::new(HashMap::new()),
309+
rand: OnceLock::new(),
232310
}
233311
}
234312

sdk/keyvault/azure_security_keyvault_keys/tests/key_client.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,8 +380,7 @@ async fn wrap_key_unwrap_key(ctx: TestContext) -> Result<()> {
380380
let version = key.resource_id()?.version.unwrap_or_default();
381381

382382
// Generate a data encryption key.
383-
// TODO: Replace with recorded randomness similar to .NET's: https://github.com/Azure/azure-sdk-for-net/blob/fefa057116832364695ca010216f66f198182647/sdk/core/Azure.Core.TestFramework/src/TestRecording.cs#L165
384-
let dek = b"17cf8194356442099ef7482b0f22340e".to_vec();
383+
let dek = recording.random::<[u8; 32]>().to_vec();
385384

386385
// Wrap the DEK.
387386
let mut parameters = KeyOperationsParameters {

0 commit comments

Comments
 (0)