Skip to content

Commit 3928505

Browse files
authored
test(auth): share fixtures for gateway audit event tests
Tests for the gateway's OCSF events each carried their own copy of the same fixtures: converting captured events to JSON, the request ID and peer address, running a handler inside a routed request, and checking event shapes. They now share them: - OcsfCapture::json_events returns captured events as their exported JSON. - auth::audit::test_support holds the test request origin, audited() for running a handler as a routed request, and assertions for authentication failures, logons, denials, boundary findings, the request origin, and redacted secrets. The event assertions also check each event against its OCSF class schema, which the handler tests did not do before. - auth::principal::test_support builds the OIDC user and gateway-JWT sandbox principals that several test modules defined identically. Refs #2912 Signed-off-by: Kris Hicks <khicks@nvidia.com>
1 parent a4c27f9 commit 3928505

12 files changed

Lines changed: 325 additions & 406 deletions

File tree

‎crates/openshell-ocsf/src/test_support.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ impl OcsfCapture {
4040
pub fn events(&self) -> Vec<OcsfEvent> {
4141
self.0.lock().expect("capture lock").clone()
4242
}
43+
44+
/// Events captured so far, as their exported JSON.
45+
///
46+
/// # Panics
47+
///
48+
/// Panics if an event does not serialize.
49+
#[must_use]
50+
pub fn json_events(&self) -> Vec<serde_json::Value> {
51+
self.events()
52+
.iter()
53+
.map(|event| event.to_json().expect("serialize OCSF event"))
54+
.collect()
55+
}
4356
}
4457

4558
#[cfg(test)]
@@ -68,5 +81,6 @@ mod tests {
6881
let events = capture.events();
6982
assert_eq!(events.len(), 1, "{events:?}");
7083
assert_eq!(events[0].base().message.as_deref(), Some("captured"));
84+
assert_eq!(capture.json_events()[0]["message"], "captured");
7185
}
7286
}

‎crates/openshell-server/src/auth/audit.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ use tonic::Status;
2222
use super::identity::IdentityProvider;
2323
use super::principal::{Principal, SandboxIdentitySource};
2424

25+
#[cfg(test)]
26+
pub mod test_support;
27+
2528
/// Where a gateway connection came from.
2629
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2730
pub enum ConnectionSource {
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Fixtures for tests that assert on gateway audit events.
5+
6+
use super::{ConnectionSource, RequestOrigin};
7+
use openshell_ocsf::test_support::OcsfCapture;
8+
use openshell_ocsf::validation::schema::{
9+
load_class_schema, validate_known_fields, validate_required_fields,
10+
};
11+
use serde_json::Value;
12+
use std::future::Future;
13+
14+
/// The `x-request-id` a test request carries.
15+
pub const REQUEST_ID: &str = "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f";
16+
17+
/// The address a test request comes from.
18+
pub const PEER: &str = "203.0.113.7:51234";
19+
20+
/// The IP address of [`PEER`] as events record it.
21+
pub const PEER_IP: &str = "203.0.113.7";
22+
23+
/// A direct connection from [`PEER`].
24+
pub fn peer() -> ConnectionSource {
25+
ConnectionSource::Peer(PEER.parse().expect("peer address"))
26+
}
27+
28+
/// A request from [`PEER`] carrying [`REQUEST_ID`].
29+
pub fn origin() -> RequestOrigin {
30+
let mut headers = http::HeaderMap::new();
31+
headers.insert("x-request-id", REQUEST_ID.parse().expect("request ID"));
32+
RequestOrigin::new(Some(peer()), &headers)
33+
}
34+
35+
/// Run `handler` as a routed request to `path` from [`origin`], returning
36+
/// its output and the OCSF events it emitted.
37+
pub async fn audited<F: Future>(path: &str, handler: F) -> (F::Output, Vec<Value>) {
38+
let capture = OcsfCapture::default();
39+
let output = {
40+
let _guard = capture.install();
41+
super::with_request(path.to_string(), origin(), handler).await
42+
};
43+
(output, capture.json_events())
44+
}
45+
46+
/// Assert that `event` records the request from [`origin`].
47+
pub fn assert_origin(event: &Value) {
48+
assert_eq!(event["src_endpoint"]["ip"], PEER_IP, "{event}");
49+
assert_eq!(event["metadata"]["correlation_uid"], REQUEST_ID, "{event}");
50+
}
51+
52+
/// Assert that `event` never mentions `secret`.
53+
pub fn assert_redacted(event: &Value, secret: &str) {
54+
assert!(
55+
!event.to_string().contains(secret),
56+
"event mentions {secret}: {event}"
57+
);
58+
}
59+
60+
fn assert_conforms(event: &Value, class: &str) {
61+
let schema = load_class_schema(class);
62+
validate_required_fields(event, &schema);
63+
validate_known_fields(event, &schema);
64+
}
65+
66+
/// Assert that `event` is an Authentication failure for `reason` on a
67+
/// request to `path`.
68+
pub fn assert_authentication_failure(event: &Value, path: &str, reason: &str) {
69+
assert_conforms(event, "authentication");
70+
assert_eq!(event["class_uid"], 3002, "{event}");
71+
assert_eq!(event["activity_id"], 1, "{event}");
72+
assert_eq!(event["status_id"], 2, "{event}");
73+
assert_eq!(event["action_id"], 2, "{event}");
74+
assert_eq!(event["disposition_id"], 2, "{event}");
75+
assert_eq!(event["status_detail"], reason, "{event}");
76+
assert_eq!(event["service"]["name"], "openshell-gateway", "{event}");
77+
assert_eq!(event["http_request"]["url"]["path"], path, "{event}");
78+
}
79+
80+
/// Assert that `event` is an Authentication logon for a request to `path`.
81+
pub fn assert_logon(event: &Value, path: &str) {
82+
assert_conforms(event, "authentication");
83+
assert_eq!(event["class_uid"], 3002, "{event}");
84+
assert_eq!(event["activity_id"], 1, "{event}");
85+
assert_eq!(event["status_id"], 1, "{event}");
86+
assert_eq!(event["action_id"], 1, "{event}");
87+
assert_eq!(event["disposition_id"], 1, "{event}");
88+
assert_eq!(event["severity_id"], 1, "{event}");
89+
assert_eq!(event["service"]["name"], "openshell-gateway", "{event}");
90+
assert_eq!(event["http_request"]["url"]["path"], path, "{event}");
91+
}
92+
93+
/// Assert that `event` is an API Activity denial for `reason` on a request
94+
/// to `path`.
95+
pub fn assert_denial(event: &Value, path: &str, reason: &str) {
96+
assert_conforms(event, "api_activity");
97+
assert_eq!(event["class_uid"], 6003, "{event}");
98+
assert_eq!(event["action_id"], 2, "{event}");
99+
assert_eq!(event["disposition_id"], 2, "{event}");
100+
assert_eq!(event["status_id"], 2, "{event}");
101+
assert_eq!(event["status_detail"], reason, "{event}");
102+
assert_eq!(event["http_request"]["url"]["path"], path, "{event}");
103+
}
104+
105+
/// Assert that `finding` is the sandbox boundary finding paired with
106+
/// `denial`.
107+
pub fn assert_boundary_finding(denial: &Value, finding: &Value) {
108+
assert_conforms(finding, "detection_finding");
109+
assert_eq!(finding["class_uid"], 2004, "{finding}");
110+
assert_eq!(
111+
finding["finding_info"]["uid"], "sandbox-authorization-boundary",
112+
"{finding}"
113+
);
114+
assert_eq!(finding["severity_id"], 4, "{finding}");
115+
assert_eq!(finding["is_alert"], true, "{finding}");
116+
let correlation_uid = &denial["metadata"]["correlation_uid"];
117+
assert!(correlation_uid.is_string(), "{denial}");
118+
assert_eq!(
119+
&finding["metadata"]["correlation_uid"], correlation_uid,
120+
"{finding}"
121+
);
122+
}

‎crates/openshell-server/src/auth/authenticator.rs‎

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -269,20 +269,8 @@ pub mod test_support {
269269
mod tests {
270270
use super::test_support::MockAuthenticator;
271271
use super::*;
272-
use crate::auth::identity::{Identity, IdentityProvider};
273-
use crate::auth::principal::UserPrincipal;
274-
275-
fn user_principal(subject: &str) -> Principal {
276-
Principal::User(UserPrincipal {
277-
identity: Identity {
278-
subject: subject.to_string(),
279-
display_name: None,
280-
roles: vec![],
281-
scopes: vec![],
282-
provider: IdentityProvider::Oidc,
283-
},
284-
})
285-
}
272+
273+
use crate::auth::principal::test_support::user;
286274

287275
#[test]
288276
fn bearer_credential_matches_scheme_case_insensitively() {
@@ -303,10 +291,10 @@ mod tests {
303291
#[tokio::test]
304292
async fn chain_returns_first_match() {
305293
let first = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted(
306-
user_principal("alice"),
294+
user("alice"),
307295
))));
308296
let second = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted(
309-
user_principal("bob"),
297+
user("bob"),
310298
))));
311299
let chain = AuthenticatorChain::new(vec![first.clone(), second.clone()]);
312300
let result = chain
@@ -333,7 +321,7 @@ mod tests {
333321
Authentication::NotApplicable,
334322
)));
335323
let second = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted(
336-
user_principal("bob"),
324+
user("bob"),
337325
))));
338326
let chain = AuthenticatorChain::new(vec![first.clone(), second.clone()]);
339327
let result = chain
@@ -358,7 +346,7 @@ mod tests {
358346
Status::unauthenticated("bad token"),
359347
))));
360348
let second = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted(
361-
user_principal("bob"),
349+
user("bob"),
362350
))));
363351
let chain = AuthenticatorChain::new(vec![first.clone(), second.clone()]);
364352
let err = chain
@@ -416,7 +404,7 @@ mod tests {
416404
peer_decline(),
417405
))));
418406
let second = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted(
419-
user_principal("bob"),
407+
user("bob"),
420408
))));
421409
let chain = AuthenticatorChain::new(vec![first, second]);
422410

‎crates/openshell-server/src/auth/guard.rs‎

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -130,30 +130,8 @@ pub fn require_operator<T>(
130130
#[cfg(test)]
131131
mod tests {
132132
use super::*;
133-
use crate::auth::identity::{Identity, IdentityProvider};
134-
use crate::auth::principal::{SandboxIdentitySource, SandboxPrincipal, UserPrincipal};
135-
136-
fn user(subject: &str) -> Principal {
137-
Principal::User(UserPrincipal {
138-
identity: Identity {
139-
subject: subject.to_string(),
140-
display_name: None,
141-
roles: vec![],
142-
scopes: vec![],
143-
provider: IdentityProvider::Oidc,
144-
},
145-
})
146-
}
147133

148-
fn sandbox(id: &str) -> Principal {
149-
Principal::Sandbox(SandboxPrincipal {
150-
sandbox_id: id.to_string(),
151-
source: SandboxIdentitySource::BootstrapJwt {
152-
issuer: "openshell-gateway:test".to_string(),
153-
},
154-
trust_domain: Some("openshell".to_string()),
155-
})
156-
}
134+
use crate::auth::principal::test_support::{sandbox, user};
157135

158136
#[test]
159137
fn user_principal_bypasses_equality_check() {

‎crates/openshell-server/src/auth/principal.rs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,37 @@ impl Principal {
124124
}
125125
}
126126

127+
/// Principals for tests.
128+
#[cfg(test)]
129+
pub mod test_support {
130+
use super::{Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal};
131+
use crate::auth::identity::{Identity, IdentityProvider};
132+
133+
/// An OIDC user with no roles or scopes.
134+
pub fn user(subject: &str) -> Principal {
135+
Principal::User(UserPrincipal {
136+
identity: Identity {
137+
subject: subject.to_string(),
138+
display_name: None,
139+
roles: vec![],
140+
scopes: vec![],
141+
provider: IdentityProvider::Oidc,
142+
},
143+
})
144+
}
145+
146+
/// A sandbox holding a gateway JWT.
147+
pub fn sandbox(sandbox_id: &str) -> Principal {
148+
Principal::Sandbox(SandboxPrincipal {
149+
sandbox_id: sandbox_id.to_string(),
150+
source: SandboxIdentitySource::BootstrapJwt {
151+
issuer: "openshell-gateway:test".to_string(),
152+
},
153+
trust_domain: Some("openshell".to_string()),
154+
})
155+
}
156+
}
157+
127158
#[cfg(test)]
128159
mod tests {
129160
use super::*;

‎crates/openshell-server/src/auth/workspace_authz.rs‎

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,10 @@ fn role_satisfies(member_role: ProtoWorkspaceRole, min_role: MinWorkspaceRole) -
276276
#[cfg(test)]
277277
mod tests {
278278
use super::*;
279+
use crate::auth::audit::test_support::assert_boundary_finding;
279280
use crate::auth::identity::{Identity, IdentityProvider};
280-
use crate::auth::principal::{SandboxIdentitySource, SandboxPrincipal, UserPrincipal};
281+
use crate::auth::principal::UserPrincipal;
282+
use crate::auth::principal::test_support::sandbox;
281283
use openshell_core::proto::datamodel::v1::ObjectMeta;
282284
use openshell_core::proto::{WorkspaceMember, WorkspaceRole as ProtoWorkspaceRole};
283285
use std::collections::HashMap;
@@ -298,16 +300,6 @@ mod tests {
298300
})
299301
}
300302

301-
fn sandbox_principal() -> Principal {
302-
Principal::Sandbox(SandboxPrincipal {
303-
sandbox_id: "sandbox-a".to_string(),
304-
source: SandboxIdentitySource::BootstrapJwt {
305-
issuer: "openshell-gateway:test".to_string(),
306-
},
307-
trust_domain: Some("openshell".to_string()),
308-
})
309-
}
310-
311303
async fn add_member(store: &Store, workspace: &str, subject: &str, role: ProtoWorkspaceRole) {
312304
let member = WorkspaceMember {
313305
metadata: Some(ObjectMeta {
@@ -442,21 +434,13 @@ mod tests {
442434
let capture = openshell_ocsf::test_support::OcsfCapture::default();
443435
let _guard = capture.install();
444436

445-
require_platform_admin("openshell-admin", &sandbox_principal())
437+
require_platform_admin("openshell-admin", &sandbox("sandbox-a"))
446438
.expect_err("sandbox must be denied");
447439

448-
let events: Vec<_> = capture
449-
.events()
450-
.iter()
451-
.map(|event| event.to_json().unwrap())
452-
.collect();
440+
let events = capture.json_events();
453441
assert_eq!(events.len(), 2, "{events:?}");
454442
assert_eq!(events[0]["status_detail"], "platform_admin_required");
455-
assert_eq!(events[1]["class_uid"], 2004);
456-
assert_eq!(
457-
events[0]["metadata"]["correlation_uid"],
458-
events[1]["metadata"]["correlation_uid"]
459-
);
443+
assert_boundary_finding(&events[0], &events[1]);
460444
}
461445

462446
#[tokio::test]
@@ -520,7 +504,7 @@ mod tests {
520504
#[tokio::test]
521505
async fn sandbox_principal_passes_through() {
522506
let store = test_store().await;
523-
let principal = sandbox_principal();
507+
let principal = sandbox("sandbox-a");
524508
let result = authorize_workspace(
525509
&store,
526510
"openshell-admin",

0 commit comments

Comments
 (0)