Skip to main content

axess_identity/
testing.rs

1//! Test fixtures for the identity layer.
2//!
3//! Two surfaces in one module (single canonical location for everything
4//! tests need from `axess-identity`):
5//!
6//! - **Deterministic-id helpers** ([`tenant`], [`user`], [`device`],
7//!   [`session`], [`event`]): label → stable [`Uuid`] v5 mapping over a
8//!   fixed test namespace. Same label → same id across the suite and
9//!   across runs.
10//! - **[`MockResolver`]**: a [`PrincipalResolver`] that returns a
11//!   pre-built [`Principal`] (or a canned [`IdentityError`]) on every
12//!   `resolve` call.
13//!
14//! Gated on the `testing` feature so production builds don't compile
15//! this surface. Downstream crates enable via
16//! `axess-identity = { ..., features = ["testing"] }` in their
17//! `[dev-dependencies]` (or forward through their own `testing` feature).
18//!
19//! ```rust,ignore
20//! use axess_identity::testing;
21//!
22//! let a = testing::tenant("acme");
23//! let b = testing::tenant("acme");
24//! let c = testing::tenant("other");
25//! assert_eq!(a, b);
26//! assert_ne!(a, c);
27//! ```
28
29use uuid::Uuid;
30
31use crate::id::{DeviceId, EventId, SessionId, TenantId, UserId};
32use crate::{IdentityError, Principal, PrincipalResolver};
33
34// ── Deterministic-id helpers ─────────────────────────────────────────────────
35
36/// Fixed namespace UUID for label → id mapping in tests. Stable across the
37/// workspace so cross-crate test fixtures agree on the bytes a given
38/// label produces.
39pub const TEST_NAMESPACE: Uuid = Uuid::from_bytes([
40    0x9a, 0x36, 0xb1, 0xfe, 0x3c, 0xf2, 0x4b, 0xa5, 0x88, 0x46, 0xe1, 0x47, 0x65, 0x09, 0xc7, 0x12,
41]);
42
43/// Stable [`TenantId`] derived from `label` via UUID v5.
44#[inline]
45pub fn tenant(label: &str) -> TenantId {
46    TenantId::from_namespaced_str(TEST_NAMESPACE, label)
47}
48
49/// Stable [`UserId`] derived from `label` via UUID v5.
50#[inline]
51pub fn user(label: &str) -> UserId {
52    UserId::from_namespaced_str(TEST_NAMESPACE, label)
53}
54
55/// Stable [`DeviceId`] derived from `label` via UUID v5.
56#[inline]
57pub fn device(label: &str) -> DeviceId {
58    DeviceId::from_namespaced_str(TEST_NAMESPACE, label)
59}
60
61/// Stable [`SessionId`] derived from `label` via UUID v5. Useful for
62/// fixture-driven session tests; production code must use
63/// [`SessionId::new`] (cryptographic opacity is the security contract).
64#[inline]
65pub fn session(label: &str) -> SessionId {
66    SessionId::from_namespaced_str(TEST_NAMESPACE, label)
67}
68
69/// Stable [`EventId`] derived from `label` via UUID v5.
70#[inline]
71pub fn event(label: &str) -> EventId {
72    EventId::from_namespaced_str(TEST_NAMESPACE, label)
73}
74
75// ── MockResolver ─────────────────────────────────────────────────────────────
76
77/// Mock [`PrincipalResolver`] returning a canned [`Principal`] or
78/// [`IdentityError`] for tests.
79#[derive(Debug, Clone)]
80pub struct MockResolver {
81    outcome: Result<Principal, MockErrorShape>,
82}
83
84/// Cloneable error shape mirroring the [`IdentityError`] variants that
85/// make sense to return from a mock resolver (the parsing variants
86/// don't; those are produced by validation paths, not by a resolver
87/// that already holds a principal).
88#[derive(Debug, Clone)]
89enum MockErrorShape {
90    NotAuthenticated,
91    InvalidComponent(String),
92}
93
94impl MockErrorShape {
95    fn rebuild(&self) -> IdentityError {
96        match self {
97            Self::NotAuthenticated => IdentityError::NotAuthenticated,
98            Self::InvalidComponent(msg) => IdentityError::InvalidComponent(msg.clone()),
99        }
100    }
101}
102
103impl MockResolver {
104    /// Construct a mock that returns `principal` on every `resolve`.
105    pub fn new(principal: Principal) -> Self {
106        Self {
107            outcome: Ok(principal),
108        }
109    }
110
111    /// Construct a mock that returns [`IdentityError::NotAuthenticated`]
112    /// on every `resolve`. Useful for testing the
113    /// no-authenticated-identity branch of consumer code.
114    pub fn not_authenticated() -> Self {
115        Self {
116            outcome: Err(MockErrorShape::NotAuthenticated),
117        }
118    }
119
120    /// Construct a mock that returns
121    /// [`IdentityError::InvalidComponent`] with the supplied message.
122    pub fn invalid_component(message: impl Into<String>) -> Self {
123        Self {
124            outcome: Err(MockErrorShape::InvalidComponent(message.into())),
125        }
126    }
127}
128
129impl PrincipalResolver for MockResolver {
130    async fn resolve(&self) -> Result<Principal, IdentityError> {
131        match &self.outcome {
132            Ok(p) => Ok(p.clone()),
133            Err(shape) => Err(shape.rebuild()),
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use std::collections::BTreeMap;
141
142    use super::*;
143    use crate::{HumanPrincipal, Issuer, TrustDomain, WorkloadId, WorkloadPrincipal};
144    use crate::{TenantId, UserId};
145
146    fn sample_human() -> Principal {
147        Principal::Human(HumanPrincipal {
148            user_id: UserId::from_bytes([1u8; 16]),
149            tenant_id: TenantId::from_bytes([2u8; 16]),
150            session_id: None,
151            attributes: BTreeMap::new(),
152        })
153    }
154
155    fn sample_workload() -> Principal {
156        let trust = TrustDomain::new("gnomes.local").unwrap();
157        let wid = WorkloadId::build(&trust, "compute-worker", "ekekrantz").unwrap();
158        Principal::Workload(WorkloadPrincipal {
159            workload_id: wid,
160            trust_domain: trust,
161            issuer: Issuer::Cli,
162            tenant_id: TenantId::from_bytes([3u8; 16]),
163            tenant_slug: "ekekrantz".to_string(),
164            service_name: "compute-worker".to_string(),
165            attributes: BTreeMap::new(),
166        })
167    }
168
169    #[tokio::test]
170    async fn mock_returns_canned_human_principal() {
171        let canned = sample_human();
172        let mock = MockResolver::new(canned.clone());
173        let resolved = mock.resolve().await.unwrap();
174        assert_eq!(resolved, canned);
175    }
176
177    #[tokio::test]
178    async fn mock_returns_canned_workload_principal() {
179        let canned = sample_workload();
180        let mock = MockResolver::new(canned.clone());
181        let resolved = mock.resolve().await.unwrap();
182        assert_eq!(resolved, canned);
183    }
184
185    #[tokio::test]
186    async fn mock_is_idempotent_across_calls() {
187        let mock = MockResolver::new(sample_human());
188        let a = mock.resolve().await.unwrap();
189        let b = mock.resolve().await.unwrap();
190        assert_eq!(a, b);
191    }
192
193    #[tokio::test]
194    async fn mock_not_authenticated_returns_error() {
195        let mock = MockResolver::not_authenticated();
196        let err = mock.resolve().await.unwrap_err();
197        assert!(matches!(err, IdentityError::NotAuthenticated));
198    }
199
200    #[tokio::test]
201    async fn mock_invalid_component_carries_message() {
202        let mock = MockResolver::invalid_component("oops");
203        let err = mock.resolve().await.unwrap_err();
204        match err {
205            IdentityError::InvalidComponent(msg) => assert_eq!(msg, "oops"),
206            other => panic!("expected InvalidComponent, got {other:?}"),
207        }
208    }
209}