Skip to content

Commit fac2882

Browse files
authored
chore(rust): add test coverage for crate edge cases (#400)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent c94a094 commit fac2882

97 files changed

Lines changed: 6595 additions & 109 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
debug
33
/rsworkspace/target/
44
/rsworkspace/crates/*/target/
5+
/target/
56
**/*.rs.bk
67
*.pdb
78
*.rlib

rsworkspace/Cargo.lock

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

rsworkspace/crates/a2a-auth-callout/src/caller_jwt_header.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ impl fmt::Display for CallerJwtHeaderValue {
3838
f.write_str("<redacted>")
3939
}
4040
}
41+
42+
#[cfg(test)]
43+
mod tests;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use super::*;
2+
use crate::jwt::MintedUserJwt;
3+
4+
#[test]
5+
fn from_minted_copies_jwt_string() {
6+
let token = MintedUserJwt::new("a.b.c").unwrap();
7+
let header = CallerJwtHeaderValue::from_minted(&token);
8+
assert_eq!(header.as_str(), "a.b.c");
9+
}
10+
11+
#[test]
12+
fn parse_accepts_trimmed_compact_jwt() {
13+
let header = CallerJwtHeaderValue::parse(" a.b.c ").unwrap();
14+
assert_eq!(header.as_str(), "a.b.c");
15+
}
16+
17+
#[test]
18+
fn parse_rejects_empty_and_whitespace_only() {
19+
assert!(matches!(
20+
CallerJwtHeaderValue::parse("").unwrap_err(),
21+
JwtError::Decode(_)
22+
));
23+
assert!(matches!(
24+
CallerJwtHeaderValue::parse(" ").unwrap_err(),
25+
JwtError::Decode(_)
26+
));
27+
}
28+
29+
#[test]
30+
fn parse_rejects_non_compact_jwt() {
31+
for bad in ["a.b", "a..c", "a.b."] {
32+
assert!(
33+
matches!(CallerJwtHeaderValue::parse(bad).unwrap_err(), JwtError::Decode(_)),
34+
"expected rejection for {bad:?}"
35+
);
36+
}
37+
}
38+
39+
#[test]
40+
fn display_redacts_value() {
41+
let header = CallerJwtHeaderValue::parse("a.b.c").unwrap();
42+
assert_eq!(header.to_string(), "<redacted>");
43+
}

rsworkspace/crates/a2a-auth-callout/src/credentials/api_key/tests.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use super::*;
22
use crate::error::CredentialError;
3+
use crate::jwt::JwtError;
34

45
fn make_registry() -> ApiKeyRegistry {
56
ApiKeyRegistry::new(b"test-hmac-secret".to_vec())
@@ -68,6 +69,15 @@ fn apikey_rejects_empty() {
6869
assert_eq!(err.to_string(), "API key must not be empty");
6970
}
7071

72+
#[test]
73+
fn caller_id_derivation_failure_maps_to_invalid_credentials() {
74+
let err: AuthCalloutError = ApiKeyError::CallerIdDerivation(JwtError::InvalidCallerId).into();
75+
let AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(message)) = err else {
76+
panic!("expected InvalidCredentials");
77+
};
78+
assert!(message.contains("caller_id derivation failed"));
79+
}
80+
7181
#[test]
7282
fn registry_collides_on_double_register() {
7383
let mut registry = make_registry();

rsworkspace/crates/a2a-auth-callout/src/credentials/mtls/tests.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
use rcgen::{BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, KeyPair};
2+
use time::OffsetDateTime;
3+
use x509_parser::pem::Pem;
24

35
use super::*;
46

@@ -75,3 +77,115 @@ async fn rejects_wrong_anchor() {
7577
AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(_))
7678
));
7779
}
80+
81+
#[test]
82+
fn rejects_pem_with_no_certificate_block() {
83+
// A PEM file with no CERTIFICATE labels causes chain_ders to return an empty
84+
// vector error.
85+
let pem = "-----BEGIN PRIVATE KEY-----\nYWJj\n-----END PRIVATE KEY-----\n";
86+
let err = X509MtlsVerifier::chain_ders(pem).unwrap_err();
87+
assert!(matches!(
88+
err,
89+
AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(_))
90+
));
91+
}
92+
93+
#[test]
94+
fn parse_cas_skips_non_certificate_pem_labels() {
95+
// A bundle where all entries are not CERTIFICATE labels: parse_cas must
96+
// skip them all and then return an empty-bundle error.
97+
let bundle = "-----BEGIN PRIVATE KEY-----\nYWJj\n-----END PRIVATE KEY-----\n";
98+
let pems: Vec<Pem> = Pem::iter_from_buffer(bundle.as_bytes())
99+
.filter_map(|r| r.ok())
100+
.collect();
101+
let err = X509MtlsVerifier::parse_cas(&pems).unwrap_err();
102+
assert!(matches!(
103+
err,
104+
AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(_))
105+
));
106+
}
107+
108+
#[tokio::test]
109+
async fn rejects_expired_certificate() {
110+
let ca_key = KeyPair::generate().expect("ca key");
111+
let mut ca_dn = DistinguishedName::new();
112+
ca_dn.push(DnType::CommonName, "test-ca");
113+
let mut ca_params = CertificateParams::default();
114+
ca_params.distinguished_name = ca_dn;
115+
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
116+
let ca = ca_params.self_signed(&ca_key).expect("ca");
117+
118+
let ee_key = KeyPair::generate().expect("ee key");
119+
let mut ee_dn = DistinguishedName::new();
120+
ee_dn.push(DnType::CommonName, "test-service");
121+
let mut ee_params = CertificateParams::default();
122+
ee_params.distinguished_name = ee_dn;
123+
let ee = ee_params.signed_by(&ee_key, &ca, &ca_key).expect("ee");
124+
125+
let v = X509MtlsVerifier::new(TrustAnchorPem::new(ca.pem()));
126+
// Use a `now` far in the past so the cert is outside its validity window.
127+
let past = OffsetDateTime::from_unix_timestamp(0).unwrap();
128+
let err = v
129+
.verify_sync(&ClientCertPem::new(ee.pem()), &AudienceAccount::new("acct"), past)
130+
.unwrap_err();
131+
assert!(matches!(
132+
err,
133+
AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(_))
134+
));
135+
}
136+
137+
#[tokio::test]
138+
async fn rejects_ca_cert_presented_as_leaf() {
139+
let ca_key = KeyPair::generate().expect("ca key");
140+
let mut ca_dn = DistinguishedName::new();
141+
ca_dn.push(DnType::CommonName, "test-ca");
142+
let mut ca_params = CertificateParams::default();
143+
ca_params.distinguished_name = ca_dn;
144+
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
145+
let ca = ca_params.self_signed(&ca_key).expect("ca");
146+
147+
// Present the CA cert itself as the client cert.
148+
let v = X509MtlsVerifier::new(TrustAnchorPem::new(ca.pem()));
149+
let now = OffsetDateTime::now_utc();
150+
let err = v
151+
.verify_sync(&ClientCertPem::new(ca.pem()), &AudienceAccount::new("acct"), now)
152+
.unwrap_err();
153+
assert!(matches!(
154+
err,
155+
AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(_))
156+
));
157+
}
158+
159+
#[tokio::test]
160+
async fn verifies_cert_that_is_itself_a_trust_anchor() {
161+
// A self-signed end-entity cert. When the same cert is placed in both the
162+
// client PEM and the trust anchor bundle it should short-circuit to
163+
// trusted = true via the "current cert is itself a configured trust anchor"
164+
// branch.
165+
let key = KeyPair::generate().expect("key");
166+
let mut dn = DistinguishedName::new();
167+
dn.push(DnType::CommonName, "self-signed-ee");
168+
let mut params = CertificateParams::default();
169+
params.distinguished_name = dn;
170+
let cert = params.self_signed(&key).expect("cert");
171+
172+
let v = X509MtlsVerifier::new(TrustAnchorPem::new(cert.pem()));
173+
let now = OffsetDateTime::now_utc();
174+
let claims = v
175+
.verify_sync(&ClientCertPem::new(cert.pem()), &AudienceAccount::new("acct"), now)
176+
.expect("should be trusted");
177+
assert_eq!(claims.aud.as_str(), "acct");
178+
}
179+
180+
#[test]
181+
fn client_cert_pem_accessors_work() {
182+
let pem = ClientCertPem::new("-----BEGIN CERTIFICATE-----\nYWJj\n-----END CERTIFICATE-----");
183+
assert!(pem.as_str().contains("CERTIFICATE"));
184+
let pem2 = pem.clone();
185+
assert_eq!(pem, pem2);
186+
187+
let anchor = TrustAnchorPem::new("bundle");
188+
assert_eq!(anchor.as_str(), "bundle");
189+
let anchor2 = anchor.clone();
190+
assert_eq!(anchor, anchor2);
191+
}

rsworkspace/crates/a2a-auth-callout/src/credentials/oidc/tests.rs

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ use std::time::Duration;
55

66
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
77
use jsonwebtoken::jwk::{
8-
AlgorithmParameters, CommonParameters, Jwk, KeyOperations, PublicKeyUse, RSAKeyParameters, RSAKeyType,
8+
AlgorithmParameters, CommonParameters, EllipticCurveKeyParameters, EllipticCurveKeyType, Jwk, KeyOperations,
9+
PublicKeyUse, RSAKeyParameters, RSAKeyType,
910
};
1011
use rand::rngs::OsRng;
1112
use rsa::RsaPrivateKey;
@@ -292,3 +293,116 @@ fn same_origin_normalizes_default_ports() {
292293
"https://idp.example.com"
293294
));
294295
}
296+
297+
#[test]
298+
fn oidc_issuer_url_strips_trailing_slashes_and_rejects_empty() {
299+
let url = OidcIssuerUrl::parse("https://idp.example.com///").unwrap();
300+
assert_eq!(url.as_str(), "https://idp.example.com");
301+
302+
let err = OidcIssuerUrl::parse("///").unwrap_err();
303+
assert!(matches!(
304+
err,
305+
AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(_))
306+
));
307+
308+
let err_empty = OidcIssuerUrl::parse("").unwrap_err();
309+
assert!(matches!(
310+
err_empty,
311+
AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(_))
312+
));
313+
}
314+
315+
#[test]
316+
fn oidc_client_id_as_str_returns_value() {
317+
let id = OidcClientId::new("my-client").unwrap();
318+
assert_eq!(id.as_str(), "my-client");
319+
}
320+
321+
#[test]
322+
fn same_origin_returns_false_when_candidate_has_no_scheme() {
323+
// No "://" separator → url_origin returns None → same_origin returns false.
324+
assert!(!super::same_origin("no-scheme", "https://idp.example.com"));
325+
}
326+
327+
#[test]
328+
fn same_origin_returns_false_when_expected_has_no_scheme() {
329+
assert!(!super::same_origin("https://idp.example.com/jwks", "no-scheme"));
330+
}
331+
332+
#[test]
333+
fn same_origin_returns_false_for_empty_host() {
334+
// scheme://... with no host component → url_origin returns None.
335+
assert!(!super::same_origin("https:///path", "https://idp.example.com"));
336+
}
337+
338+
#[tokio::test]
339+
async fn verify_fails_with_non_rsa_jwk() {
340+
let issuer = OidcIssuerUrl::parse("https://issuer.example").unwrap();
341+
let ec_jwk = Jwk {
342+
common: CommonParameters {
343+
key_id: Some("ec-kid".into()),
344+
..Default::default()
345+
},
346+
algorithm: AlgorithmParameters::EllipticCurve(EllipticCurveKeyParameters {
347+
key_type: EllipticCurveKeyType::EC,
348+
curve: jsonwebtoken::jwk::EllipticCurve::P256,
349+
x: "dummyx".into(),
350+
y: "dummyy".into(),
351+
}),
352+
};
353+
let jwks = JwkSet { keys: vec![ec_jwk] };
354+
let verifier = JwksOidcVerifier::with_static_jwks(issuer, vec!["aud".into()], jwks);
355+
356+
// Craft a fake JWT whose kid matches the EC JWK; decode_header will succeed
357+
// but decoding_key_for_jwk must reject the non-RSA key.
358+
// We can't sign with the EC key easily, but we can make a header-only token
359+
// that references the EC kid. decode_header just parses the header.
360+
let header_b64 =
361+
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","kid":"ec-kid","typ":"JWT"}"#);
362+
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{}");
363+
let fake_token = format!("{header_b64}.{payload_b64}.sig");
364+
365+
let err = verifier
366+
.verify_internal(&BearerToken::new(fake_token), &AudienceAccount::new("acct"))
367+
.await
368+
.unwrap_err();
369+
assert!(matches!(
370+
err,
371+
AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(_))
372+
));
373+
}
374+
375+
#[tokio::test]
376+
async fn oidc_verifier_trait_delegates_to_verify_internal() {
377+
// Exercise the OidcVerifier::verify blanket impl on JwksOidcVerifier.
378+
let rng = &mut OsRng;
379+
let (jwks, enc) = test_jwks_and_encoding_key(rng);
380+
let issuer = OidcIssuerUrl::parse("https://issuer.example").unwrap();
381+
let verifier: &dyn OidcVerifier =
382+
&JwksOidcVerifier::with_static_jwks(issuer.clone(), vec!["a2a-client".into()], jwks);
383+
#[derive(Serialize)]
384+
struct IdClaims {
385+
sub: String,
386+
iss: String,
387+
aud: String,
388+
exp: u64,
389+
}
390+
let now = std::time::SystemTime::now()
391+
.duration_since(std::time::UNIX_EPOCH)
392+
.unwrap()
393+
.as_secs();
394+
let id = IdClaims {
395+
sub: "user-1".into(),
396+
iss: issuer.as_str().to_owned(),
397+
aud: "a2a-client".into(),
398+
exp: now + 600,
399+
};
400+
let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
401+
header.kid = Some("test-kid".into());
402+
let token = jsonwebtoken::encode(&header, &id, &enc).expect("encode");
403+
let claims = verifier
404+
.verify(&BearerToken::new(token), &AudienceAccount::new("nats-acct"))
405+
.await
406+
.expect("verify via trait");
407+
assert_eq!(claims.sub.as_str(), "user-1");
408+
}

rsworkspace/crates/a2a-auth-callout/src/denial_category/tests.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,31 @@ fn credential_invalid_request_maps_to_invalid_request() {
9696
DenialCategory::InvalidRequest
9797
);
9898
}
99+
100+
#[test]
101+
fn key_load_variants_map_to_internal_error() {
102+
assert_eq!(
103+
DenialCategory::from_auth_callout_error(&AuthCalloutError::MissingEnvVar("SIGNING_KEY")),
104+
DenialCategory::InternalError
105+
);
106+
assert_eq!(
107+
DenialCategory::from_auth_callout_error(&AuthCalloutError::UnknownSigningKeySource("vault".into())),
108+
DenialCategory::InternalError
109+
);
110+
assert_eq!(
111+
DenialCategory::from_auth_callout_error(&AuthCalloutError::VaultNotConfigured),
112+
DenialCategory::InternalError
113+
);
114+
assert_eq!(
115+
DenialCategory::from_auth_callout_error(&AuthCalloutError::KeyLoadIo {
116+
path: std::path::PathBuf::from("/etc/key"),
117+
source: std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"),
118+
}),
119+
DenialCategory::InternalError
120+
);
121+
let utf8_err = String::from_utf8(vec![0x80, 0x80]).unwrap_err().utf8_error();
122+
assert_eq!(
123+
DenialCategory::from_auth_callout_error(&AuthCalloutError::KeyLoadUtf8(utf8_err)),
124+
DenialCategory::InternalError
125+
);
126+
}

0 commit comments

Comments
 (0)