Skip to content

Commit 67450e3

Browse files
simorenohCopilot
andcommitted
Add AAD integration tests for Cosmos DB
Adds the first AAD-authenticated integration test coverage for the Cosmos SDK, closing a GA-readiness gap where no test exercised the AAD token path. - Add CosmosEmulatorCredential, a TokenCredential that mints the emulator's fake JWT, plus a CredentialRecorder so tests can assert the AAD path (not key auth) was used and which scope was requested. - Add build_aad_client_from_env / TestRunContext::aad_client(), selecting the credential by host: emulator uses CosmosEmulatorCredential; live accounts use azure_core_test credentials from env (AzurePipelinesCredential in CI), matching the principal the test bicep grants the data-plane RBAC role to. - Add cosmos_aad.rs with two dual-client tests: aad_item_crud_roundtrip (create/read/replace/query/delete via AAD) and aad_read_container_metadata. The key client handles db/container setup and cleanup since data-plane RBAC cannot create them. - Enable AAD on the Windows emulator launch in Invoke-CosmosTestSetup.ps1 so the gated tests pass on the Windows Build leg (the Docker path already enabled it). The tests are gated on test_category=emulator, so they run on both the Build-stage emulator and the live SingleWrite legs, giving real-AAD coverage on scheduled/weekly live runs at no extra config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 53cb41e commit 67450e3

6 files changed

Lines changed: 446 additions & 2 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
//! Integration tests exercising Entra ID (AAD) authentication against Azure
5+
//! Cosmos DB.
6+
//!
7+
//! These tests use a **dual-client** pattern: a key-auth client (provided by the
8+
//! framework) performs database/container management, while a separate
9+
//! AAD-authenticated client performs all data-plane item operations. This
10+
//! mirrors the data-plane RBAC role provisioned in `test-resources.bicep`, which
11+
//! grants item/metadata data actions but **not** management-plane permissions.
12+
//!
13+
//! Because the standard `test_category="emulator"` gate is used, the same tests
14+
//! run against the local emulator (Build stage, with the emulator started using
15+
//! `/enableaadauthentication`) and against live accounts (LiveTest stage), where
16+
//! the framework selects a real Entra ID credential via
17+
//! `azure_core_test::credentials::from_env`.
18+
19+
use super::framework;
20+
21+
use azure_core::http::StatusCode;
22+
use azure_core::Uuid;
23+
use azure_data_cosmos::models::ContainerProperties;
24+
use azure_data_cosmos::query::FeedScope;
25+
use azure_data_cosmos::{PartitionKey, Query};
26+
use framework::{TestClient, TestRunContext};
27+
use futures::TryStreamExt;
28+
use serde::{Deserialize, Serialize};
29+
use std::error::Error;
30+
31+
/// The scope the Cosmos driver requests when acquiring an AAD token.
32+
const COSMOS_AAD_SCOPE: &str = "https://cosmos.azure.com/.default";
33+
34+
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
35+
struct AadTestItem {
36+
id: String,
37+
partition_key: String,
38+
value: i64,
39+
}
40+
41+
/// Drives a full item CRUD round-trip through an AAD-authenticated client.
42+
///
43+
/// Setup (database + container) and teardown run through the framework's
44+
/// key-auth client; only the item operations use the AAD client. On the
45+
/// emulator we additionally assert the bespoke fake-JWT credential was actually
46+
/// invoked for the Cosmos scope, guarding against silently exercising key auth.
47+
#[tokio::test]
48+
#[cfg_attr(
49+
not(any(test_category = "emulator", test_category = "emulator_vnext")),
50+
ignore = "requires test_category 'emulator' or 'emulator_vnext'"
51+
)]
52+
pub async fn aad_item_crud_roundtrip() -> Result<(), Box<dyn Error>> {
53+
TestClient::run_with_unique_db(
54+
async |run_context: &TestRunContext, db_client| {
55+
// Key client creates the container (management-plane operation).
56+
let container_id = format!("aad-container-{}", Uuid::new_v4());
57+
run_context
58+
.create_container(
59+
db_client,
60+
ContainerProperties::new(container_id.clone(), "/partition_key".into()),
61+
None,
62+
)
63+
.await?;
64+
65+
// Build the AAD-authenticated client and address the same container.
66+
let (aad_client, recorder) = run_context.aad_client().await?;
67+
let aad_container = aad_client
68+
.database_client(db_client.id())
69+
.container_client(&container_id)
70+
.await?;
71+
72+
let unique = Uuid::new_v4().to_string();
73+
let pk = format!("pk-{unique}");
74+
let item_id = format!("item-{unique}");
75+
let mut item = AadTestItem {
76+
id: item_id.clone(),
77+
partition_key: pk.clone(),
78+
value: 1,
79+
};
80+
81+
// Create via AAD.
82+
let create = aad_container
83+
.create_item(&pk, &item_id, &item, None)
84+
.await?;
85+
assert_eq!(
86+
create.status(),
87+
StatusCode::Created,
88+
"AAD create_item should succeed"
89+
);
90+
91+
// Point read via AAD.
92+
let read = aad_container.read_item(&pk, &item_id, None).await?;
93+
assert_eq!(read.status(), StatusCode::Ok);
94+
assert_eq!(
95+
read.into_model::<AadTestItem>()?,
96+
item,
97+
"round-tripped item should match"
98+
);
99+
100+
// Replace via AAD.
101+
item.value = 2;
102+
let replace = aad_container
103+
.replace_item(&pk, &item_id, &item, None)
104+
.await?;
105+
assert_eq!(replace.status(), StatusCode::Ok);
106+
107+
// Query via AAD, scoped to the item's partition.
108+
let query =
109+
Query::from("SELECT * FROM c WHERE c.id = @id").with_parameter("@id", &item_id)?;
110+
let found: Vec<AadTestItem> = aad_container
111+
.query_items::<AadTestItem>(
112+
query,
113+
FeedScope::partition(PartitionKey::from(&pk)),
114+
None,
115+
)
116+
.await?
117+
.try_collect()
118+
.await?;
119+
assert_eq!(found.len(), 1, "query should return exactly the one item");
120+
assert_eq!(found[0], item);
121+
122+
// Delete via AAD.
123+
let delete = aad_container.delete_item(&pk, &item_id, None).await?;
124+
assert_eq!(delete.status(), StatusCode::NoContent);
125+
126+
// On the emulator, prove the AAD credential was actually exercised.
127+
if let Some(recorder) = recorder {
128+
assert!(
129+
recorder.call_count() > 0,
130+
"emulator AAD credential get_token was never called"
131+
);
132+
assert!(
133+
recorder.requested_scope(COSMOS_AAD_SCOPE),
134+
"expected the Cosmos scope ({COSMOS_AAD_SCOPE}) to be requested, got {:?}",
135+
recorder.requested_scopes()
136+
);
137+
}
138+
139+
Ok(())
140+
},
141+
None,
142+
)
143+
.await
144+
}
145+
146+
/// Verifies an AAD-authenticated client can read container metadata, exercising
147+
/// the `readMetadata` data action the SDK requires on its first request.
148+
#[tokio::test]
149+
#[cfg_attr(
150+
not(any(test_category = "emulator", test_category = "emulator_vnext")),
151+
ignore = "requires test_category 'emulator' or 'emulator_vnext'"
152+
)]
153+
pub async fn aad_read_container_metadata() -> Result<(), Box<dyn Error>> {
154+
TestClient::run_with_unique_db(
155+
async |run_context: &TestRunContext, db_client| {
156+
let container_id = format!("aad-meta-{}", Uuid::new_v4());
157+
run_context
158+
.create_container(
159+
db_client,
160+
ContainerProperties::new(container_id.clone(), "/partition_key".into()),
161+
None,
162+
)
163+
.await?;
164+
165+
let (aad_client, recorder) = run_context.aad_client().await?;
166+
let aad_container = aad_client
167+
.database_client(db_client.id())
168+
.container_client(&container_id)
169+
.await?;
170+
171+
let properties = aad_container.read(None).await?.into_model()?;
172+
assert_eq!(
173+
properties.id, container_id,
174+
"AAD container read should return the same container"
175+
);
176+
177+
if let Some(recorder) = recorder {
178+
assert!(
179+
recorder.requested_scope(COSMOS_AAD_SCOPE),
180+
"expected the Cosmos scope to be requested via AAD"
181+
);
182+
}
183+
184+
Ok(())
185+
},
186+
None,
187+
)
188+
.await
189+
}

sdk/cosmos/azure_data_cosmos/tests/emulator_tests/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
3+
mod cosmos_aad;
34
mod cosmos_backup_endpoints;
45
mod cosmos_batch;
56
mod cosmos_containers;
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
//! A [`TokenCredential`] that mints the fake JWT the Azure Cosmos DB emulator
5+
//! accepts when started with `/enableaadauthentication`.
6+
//!
7+
//! The emulator does not validate tokens against Entra ID. Instead it accepts a
8+
//! self-signed JWT whose signature segment is the emulator's well-known master
9+
//! key (encoded as base64url, no padding). This mirrors the
10+
//! `CosmosEmulatorCredential` used by the Python SDK's AAD emulator tests.
11+
//!
12+
//! This credential is for **tests only**. Never use it against a real account.
13+
14+
use azure_core::credentials::{AccessToken, TokenCredential, TokenRequestOptions};
15+
use azure_core::time::OffsetDateTime;
16+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
17+
use base64::Engine as _;
18+
use std::sync::atomic::{AtomicUsize, Ordering};
19+
use std::sync::{Arc, Mutex};
20+
21+
/// The well-known master key the Cosmos DB emulator ships with. The emulator
22+
/// validates the JWT signature segment against this exact string.
23+
pub const EMULATOR_MASTER_KEY: &str =
24+
"C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
25+
26+
/// Records how a [`CosmosEmulatorCredential`] was exercised so tests can assert
27+
/// the AAD path (and not key auth) was actually used.
28+
#[derive(Clone, Default)]
29+
pub struct CredentialRecorder {
30+
call_count: Arc<AtomicUsize>,
31+
scopes: Arc<Mutex<Vec<String>>>,
32+
}
33+
34+
impl CredentialRecorder {
35+
/// Number of times `get_token` was called.
36+
pub fn call_count(&self) -> usize {
37+
self.call_count.load(Ordering::SeqCst)
38+
}
39+
40+
/// All scopes that were requested across every `get_token` call.
41+
pub fn requested_scopes(&self) -> Vec<String> {
42+
self.scopes.lock().unwrap().clone()
43+
}
44+
45+
/// Returns `true` if any `get_token` call requested `scope`.
46+
pub fn requested_scope(&self, scope: &str) -> bool {
47+
self.scopes.lock().unwrap().iter().any(|s| s == scope)
48+
}
49+
}
50+
51+
/// A [`TokenCredential`] that produces the emulator's fake AAD token.
52+
#[derive(Debug, Clone)]
53+
pub struct CosmosEmulatorCredential {
54+
master_key: String,
55+
call_count: Arc<AtomicUsize>,
56+
scopes: Arc<Mutex<Vec<String>>>,
57+
}
58+
59+
impl std::fmt::Debug for CredentialRecorder {
60+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61+
f.debug_struct("CredentialRecorder")
62+
.field("call_count", &self.call_count())
63+
.finish_non_exhaustive()
64+
}
65+
}
66+
67+
impl CosmosEmulatorCredential {
68+
/// Creates a credential using the emulator's well-known master key.
69+
pub fn new() -> Self {
70+
Self::with_master_key(EMULATOR_MASTER_KEY)
71+
}
72+
73+
/// Creates a credential signing tokens with a specific master key string.
74+
///
75+
/// The `master_key` must be the same key the emulator was started with; the
76+
/// emulator validates the JWT signature segment against it byte-for-byte.
77+
pub fn with_master_key(master_key: impl Into<String>) -> Self {
78+
Self {
79+
master_key: master_key.into(),
80+
call_count: Arc::new(AtomicUsize::new(0)),
81+
scopes: Arc::new(Mutex::new(Vec::new())),
82+
}
83+
}
84+
85+
/// Returns a [`CredentialRecorder`] that observes how this credential is used.
86+
pub fn recorder(&self) -> CredentialRecorder {
87+
CredentialRecorder {
88+
call_count: self.call_count.clone(),
89+
scopes: self.scopes.clone(),
90+
}
91+
}
92+
93+
/// Builds the emulator fake JWT: `b64url(header).b64url(claims).b64url(key)`.
94+
fn build_token(&self, expires_on: OffsetDateTime) -> String {
95+
// Header advertises the emulator's well-known signing key identifier.
96+
let header = r#"{"typ":"JWT","alg":"RS256","x5t":"CosmosEmulatorPrimaryMaster","kid":"CosmosEmulatorPrimaryMaster"}"#;
97+
98+
let now = OffsetDateTime::now_utc().unix_timestamp();
99+
// Allow for some clock skew between the test host and the emulator.
100+
let not_before = now - 300;
101+
let issued_at = now - 300;
102+
let expiry = expires_on.unix_timestamp();
103+
104+
let claims = format!(
105+
concat!(
106+
"{{",
107+
r#""aud":"https://localhost.localhost","#,
108+
r#""iss":"https://sts.fake-issuer.net/7b1999a1-dfd7-440e-8204-00170979b984","#,
109+
r#""iat":{iat},"nbf":{nbf},"exp":{exp},"#,
110+
r#""aio":"","appid":"localhost","appidacr":"1","#,
111+
r#""idp":"https://localhost.localhost","oid":"96313034-4739-43cb-93cd-74193adbe5b6","#,
112+
r#""rh":"","sub":"localhost","#,
113+
r#""tid":"EmulatorFederation","#,
114+
r#""unique_name":"localhost","uti":"","#,
115+
r#""ver":"1.0","#,
116+
r#""scp":"user_impersonation","#,
117+
r#""groups":["7ce1d3a1-6df6-4e88-bb0d-1c5e1c6c2b21","e99e9b3e-9c4f-4e5e-9e0a-2b9a4b2c3d4e","b2b3b4b5-c6c7-48d9-9e0a-2b9a4b2c3d4e","c3c4c5c6-d7d8-49e0-9f0b-3c0b5c3d4e5f","d4d5d6d7-e8e9-4af1-a01c-4d1c6d4e5f60"]"#,
118+
"}}",
119+
),
120+
iat = issued_at,
121+
nbf = not_before,
122+
exp = expiry,
123+
);
124+
125+
let header_segment = URL_SAFE_NO_PAD.encode(header.as_bytes());
126+
let claims_segment = URL_SAFE_NO_PAD.encode(claims.as_bytes());
127+
// The emulator validates this segment against its master key string bytes.
128+
let signature_segment = URL_SAFE_NO_PAD.encode(self.master_key.as_bytes());
129+
130+
format!("{header_segment}.{claims_segment}.{signature_segment}")
131+
}
132+
}
133+
134+
impl Default for CosmosEmulatorCredential {
135+
fn default() -> Self {
136+
Self::new()
137+
}
138+
}
139+
140+
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
141+
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
142+
impl TokenCredential for CosmosEmulatorCredential {
143+
async fn get_token(
144+
&self,
145+
scopes: &[&str],
146+
_options: Option<TokenRequestOptions<'_>>,
147+
) -> azure_core::Result<AccessToken> {
148+
self.call_count.fetch_add(1, Ordering::SeqCst);
149+
{
150+
let mut recorded = self.scopes.lock().unwrap();
151+
recorded.extend(scopes.iter().map(|s| s.to_string()));
152+
}
153+
154+
let expires_on = OffsetDateTime::now_utc() + azure_core::time::Duration::hours(2);
155+
let token = self.build_token(expires_on);
156+
Ok(AccessToken::new(token, expires_on))
157+
}
158+
}

sdk/cosmos/azure_data_cosmos/tests/framework/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
//!
1515
//! The framework allows tests to easily run against real Cosmos DB instances, the local emulator, or a mock server using test-proxy.
1616
17+
pub mod emulator_credential;
1718
pub mod mock_account;
1819
pub mod test_client;
1920
pub mod test_data;
2021

22+
pub use emulator_credential::{CosmosEmulatorCredential, CredentialRecorder};
2123
pub use test_client::{
2224
assert_local_retry_attempted_on_region, assert_region_contacted_with_retry,
2325
get_effective_hub_endpoint, get_global_endpoint, resolve_connection_string, TestClient,

0 commit comments

Comments
 (0)