Skip to main content

axess_core/
testing.rs

1//! Deterministic-simulation-testing (DST) fixtures: mocks, fakes, and
2//! record builders that adopter test suites can use against `axess-core`'s
3//! production traits.
4//!
5//! The whole module is gated behind `#[cfg(any(test, feature = "testing"))]`
6//! so production builds cannot import test doubles. Adopters writing
7//! integration tests against `AuthnService`, `AuthzStore`, `SessionLayer`
8//! etc. need to enable the feature on their dev-dependency line:
9//!
10//! ```toml
11//! [dev-dependencies]
12//! axess = { version = "0.1", features = ["testing"] }
13//! ```
14//!
15//! Individual mocks inside this module may carry *additional* gates tied
16//! to the production feature they mock (`authz`, `local-idp`); those are
17//! gated on the prod feature, not on a separate `testing` flag.
18//!
19//! ## What lives here
20//!
21//! Two categories of test double share this module. *Mocks* are
22//! trait-shaped substitutes that simulate a single surface (a store, a
23//! random source, a clock). *Fixtures* are working in-process
24//! implementations that test code can drive directly without standing
25//! up external infrastructure. The categories are listed separately
26//! because they behave differently: a mock answers what the test tells
27//! it to answer; a fixture runs the same code path the production
28//! variant would, just against in-memory state.
29//!
30//! ### Mocks
31//!
32//! - `mock_authn`: `MockFactorStore` / `MockIdentityStore` / `MockStoreError`.
33//! - `mock_clock`: re-export of [`axess_clock::testing::MockClock`].
34//! - `mock_random`: re-export of [`axess_rng::testing::MockRng`].
35//! - `mock_refresh_store`: `MemoryRefreshTokenStore` + error type.
36//! - `mock_tracing`: `tracing-subscriber` helper for capturing spans in tests.
37//! - `mock_policy` (feature `authz`): Cedar policy fixtures.
38//! - `MockResolver` (re-exported from [`axess_identity::testing`]): `PrincipalResolver` stub.
39//!
40//! ### Fixtures
41//!
42//! - `local_idp` (feature `local-idp`): in-process IdP fixture for
43//!   workload-identity tests. Shares signing primitives with the
44//!   production [`LocalIdp`](crate::local_idp::LocalIdp); the primitives
45//!   live in [`crate::local_idp::primitives`] (out of this `testing`
46//!   tree) so production code does not have to import from here.
47//! - `oauth_wiremock` (feature `testing-oauth`): OIDC IdP-against-wiremock
48//!   fixture. Mounts a discovery document, JWKS endpoint, and gives the
49//!   caller an [`OAuthProviderConfig`](axess_factors::oauth::OAuthProviderConfig)
50//!   wired to the in-process server. Adopters use this to exercise the
51//!   `begin_oauth_login` / `finish_oauth_login` / `complete_oauth_login`
52//!   ceremony without provisioning a real IdP.
53//!
54//! ### Other helpers
55//!
56//! - Record builders: `user_record`, `tenant_record`, `test_session`.
57//! - `AuthSessionTestExt`: re-exposes `pub(crate)` state-mutation methods
58//!   on [`AuthSession`](crate::session::extractor::AuthSession) for fixtures
59//!   that need to plant a session in a known state.
60
61pub mod mock_authn;
62pub mod mock_clock;
63pub mod mock_random;
64pub mod mock_refresh_store;
65
66pub mod mock_tracing;
67
68// Authz mocks are not restricted to #[cfg(test)] so that downstream crates
69// can use them in their own test suites.
70#[cfg(feature = "authz")]
71pub mod mock_policy;
72
73/// In-process IdP test fixture; see [`local_idp`] module docs.
74/// Gated on the `local-idp` feature (pulls in `rsa`).
75#[cfg(feature = "local-idp")]
76pub mod local_idp;
77
78/// OIDC IdP-against-wiremock fixture for adopter test suites that drive the
79/// OAuth/OIDC ceremony end-to-end. Gated on the `testing-oauth` feature
80/// (pulls in `wiremock` + `rsa`).
81#[cfg(feature = "testing-oauth")]
82pub mod oauth_wiremock;
83
84pub use mock_authn::{MockFactorStore, MockIdentityStore, MockStoreError};
85pub use mock_clock::MockClock;
86pub use mock_random::MockRng;
87pub use mock_refresh_store::{MemoryRefreshStoreError, MemoryRefreshTokenStore};
88
89// `MockResolver` lives in the leaf `axess-identity` crate so
90// adopters who depend on `axess-identity` directly (without all of
91// axess-core) can still use it in their test suites. axess-core
92// re-exports it here for callers that import everything from one
93// place; matches the shape of the other DST mocks in this module.
94pub use axess_identity::testing::MockResolver;
95
96/// Build a ready-to-use [`User`](crate::authn::types::User) record from a
97/// short label, matching the [`axess_identity::testing::user`] id helper.
98///
99/// `label` drives a stable UUID v5 for the user id; the same label always
100/// produces the same id across the workspace, so test fixtures that
101/// rebuild a user agree on its bytes. The tenant id is derived from
102/// `tenant_label` via the same scheme. Other fields default to:
103/// `identifier = display_name = label`,
104/// `status = EntityState::Active`, `webauthn_id = None`,
105/// `created_by = updated_by = UserId::system()`,
106/// `created_at = updated_at = chrono::Utc::now()`.
107///
108/// For deterministic timestamps inject a `MockClock` and call its `now()`,
109/// then overwrite the `created_at` / `updated_at` fields.
110pub fn user_record(label: &str, tenant_label: &str) -> crate::authn::types::User {
111    let now = chrono::Utc::now();
112    crate::authn::types::User {
113        id: axess_identity::testing::user(label),
114        tenant_id: axess_identity::testing::tenant(tenant_label),
115        identifier: label.into(),
116        display_name: label.into(),
117        status: crate::authn::types::EntityState::Active,
118        webauthn_id: None,
119        created_by: crate::authn::ids::UserId::system(),
120        created_at: now,
121        updated_by: crate::authn::ids::UserId::system(),
122        updated_at: now,
123    }
124}
125
126/// Build a ready-to-use [`Tenant`](crate::authn::types::Tenant) record
127/// from a short label, matching the [`axess_identity::testing::tenant`]
128/// id helper. Mirror of [`user_record`].
129pub fn tenant_record(label: &str) -> crate::authn::types::Tenant {
130    let now = chrono::Utc::now();
131    crate::authn::types::Tenant {
132        id: axess_identity::testing::tenant(label),
133        identifier: label.into(),
134        display_name: label.into(),
135        status: crate::authn::types::EntityState::Active,
136        created_by: crate::authn::ids::UserId::system(),
137        created_at: now,
138        updated_by: crate::authn::ids::UserId::system(),
139        updated_at: now,
140    }
141}
142
143/// Create an `AuthSession` with a fresh session ID for use in tests.
144///
145/// This avoids needing access to `pub(crate)` constructors from integration tests.
146pub fn test_session() -> crate::session::extractor::AuthSession {
147    use crate::session::{
148        data::SessionData,
149        id::SessionId,
150        layer::{SessionHandle, SessionInner},
151    };
152    use std::sync::Arc;
153    use tokio::sync::RwLock;
154
155    let inner = SessionInner {
156        id: SessionId::new(&axess_rng::SystemRng),
157        data: SessionData::default(),
158        modified: false,
159        regenerate: false,
160        pre_cycle_id: None,
161        pending_fingerprint: None,
162        max_custom_bytes: 64 * 1024,
163    };
164    crate::session::extractor::AuthSession(SessionHandle(Arc::new(RwLock::new(inner))))
165}
166
167/// Test-fixture extension trait that re-exposes the
168/// state-mutation methods on
169/// [`AuthSession`](crate::session::extractor::AuthSession). The
170/// inherent methods (`set_authenticated`, `begin_authenticating`,
171/// `advance_factor`, `record_attempt_at`) are `pub(crate)` so handler
172/// code can't corrupt the factor-pipeline state machine by calling
173/// them directly; the service-side login flow drives them through
174/// audited entry points. Integration tests legitimately need to plant
175/// a session in a fixture state; importing this trait makes those
176/// methods callable on `AuthSession` again. Method names match the
177/// inherent surface so existing test code only needs the new `use`
178/// line: no per-site rewrite.
179///
180/// Always-on (not behind `cfg(test)`) so adopter integration tests
181/// pick up the trait via `axess-core` in `[dev-dependencies]`.
182/// Importing `AuthSessionTestExt` in handler code is itself a strong
183/// review signal that the handler is reaching past the audited entry
184/// points; code reviewers should treat the `use` line as a red flag.
185pub use session_ext::AuthSessionTestExt;
186
187mod session_ext {
188    use crate::authn::factor::FactorKind;
189    use crate::authn::ids::{TenantId, UserId};
190    use crate::session::extractor::AuthSession;
191    use chrono::{DateTime, Utc};
192    use std::sync::Arc;
193
194    /// Extension trait; see module-level docs for the rationale.
195    pub trait AuthSessionTestExt {
196        /// Force the session into [`AuthState::Authenticated`](crate::session::data::AuthState::Authenticated).
197        fn set_authenticated(
198            &self,
199            user_id: UserId,
200            tenant_id: TenantId,
201            authn_time: DateTime<Utc>,
202        ) -> impl std::future::Future<Output = ()> + Send;
203
204        /// Force the session into [`AuthState::Authenticating`](crate::session::data::AuthState::Authenticating)
205        /// with the given factor sequence.
206        fn begin_authenticating(
207            &self,
208            user_id: UserId,
209            tenant_id: TenantId,
210            method_name: Arc<str>,
211            factors: Vec<FactorKind>,
212        ) -> impl std::future::Future<Output = ()> + Send;
213
214        /// Drive one factor-pipeline step from the test side,
215        /// advancing the in-session `remaining` queue and transitioning
216        /// to [`AuthState::Authenticated`](crate::session::data::AuthState::Authenticated)
217        /// when empty.
218        fn advance_factor(
219            &self,
220            kind: &FactorKind,
221            authn_time: DateTime<Utc>,
222        ) -> impl std::future::Future<Output = ()> + Send;
223
224        /// Stamp a failed-attempt timestamp on the session without
225        /// touching the persistent lockout counter; mirrors what the
226        /// factor pipeline does on credential mismatch, but
227        /// addressable from a unit test.
228        fn record_attempt_at(
229            &self,
230            now: DateTime<Utc>,
231        ) -> impl std::future::Future<Output = ()> + Send;
232    }
233
234    impl AuthSessionTestExt for AuthSession {
235        async fn set_authenticated(
236            &self,
237            user_id: UserId,
238            tenant_id: TenantId,
239            authn_time: DateTime<Utc>,
240        ) {
241            // Fully-qualified resolves to the pub(crate) inherent
242            // method on AuthSession; the trait method body runs
243            // inside axess-core where the inherent is visible.
244            AuthSession::set_authenticated(self, user_id, tenant_id, authn_time).await;
245        }
246
247        async fn begin_authenticating(
248            &self,
249            user_id: UserId,
250            tenant_id: TenantId,
251            method_name: Arc<str>,
252            factors: Vec<FactorKind>,
253        ) {
254            AuthSession::begin_authenticating(self, user_id, tenant_id, method_name, factors).await;
255        }
256
257        async fn advance_factor(&self, kind: &FactorKind, authn_time: DateTime<Utc>) {
258            AuthSession::advance_factor(self, kind, authn_time).await;
259        }
260
261        async fn record_attempt_at(&self, now: DateTime<Utc>) {
262            AuthSession::record_attempt_at(self, now).await;
263        }
264    }
265}