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}