axess_identity/id.rs
1//! Typed identifier primitives for the axess workspace.
2//!
3//! Every entity identifier in axess-core (auth principals, sessions,
4//! devices) and axess-events (envelope identity) uses the same shape:
5//! `pub struct FooId(uuid::Uuid)`, 16 bytes, `Copy`, with random UUID v4
6//! bytes minted from the workspace [`SecureRng`](axess_rng::SecureRng)
7//! at creation. Construction splits into two camps: adopter-supplied
8//! ids (`TenantId`, `UserId`) accept whatever the adopter brings via
9//! `from_uuid` / `from_namespaced_str` / `from_bytes` and surface
10//! `::SYSTEM` sentinels; axess-minted ids (`DeviceId`, `SessionId`,
11//! `EventId`) call `FooId::new(rng)` because opacity is the security
12//! contract.
13//!
14//! The [`define_id!`](crate::define_id!) macro declares custom domain
15//! ids with the same machinery.
16//!
17//! # Example
18//!
19//! ```
20//! use axess_identity::define_id;
21//!
22//! define_id! {
23//! /// Account identifier, globally unique across the platform.
24//! pub AccountId
25//! }
26//!
27//! let mut rng = axess_rng::testing::MockRng::new(42);
28//! let id = AccountId::new(&mut rng);
29//! assert!(!id.is_nil());
30//! ```
31//!
32//! Test fixtures live in [`testing`], which derives ids deterministically
33//! from string labels over a fixed workspace test namespace.
34
35#![deny(unsafe_code)]
36#![deny(missing_docs)]
37#![cfg_attr(docsrs, feature(doc_cfg))]
38
39pub use uuid::Uuid;
40
41/// Validation failure for [`define_id!`](crate::define_id!)'s `try_new` /
42/// [`ensure_user_id_not_reserved`].
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum IdError {
46 /// The identifier was empty.
47 Empty(&'static str),
48 /// The identifier was not a valid hyphenated Uuid.
49 NotAUuid(&'static str),
50 /// The identifier matched a reserved system sentinel and the
51 /// calling guard refused to accept it.
52 Reserved(&'static str),
53}
54
55impl core::fmt::Display for IdError {
56 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
57 match self {
58 IdError::Empty(kind) => write!(f, "{kind} must be non-empty"),
59 IdError::NotAUuid(kind) => write!(f, "{kind} must be a valid Uuid"),
60 IdError::Reserved(kind) => write!(f, "{kind} matches reserved system identifier"),
61 }
62 }
63}
64
65impl std::error::Error for IdError {}
66
67/// Mint 16 cryptographically random bytes into a UUID v4 (sets the
68/// version + RFC 4122 variant bits per spec).
69///
70/// **Prefer the typed `FooId::new(&mut rng)` constructor generated by
71/// [`define_id!`](crate::define_id!).** This bare-`Uuid` mint is an
72/// escape hatch for niche cases (a foreign-key column with no typed
73/// wrapper yet, or interop with an external API that demands a raw
74/// `Uuid`). Direct use in domain crates erodes the type-discrimination
75/// property that the typed-id newtypes provide.
76///
77/// **DST discipline.** This function is the only sanctioned random mint
78/// path in the workspace; it routes through the injected
79/// [`SecureRng`](axess_rng::SecureRng), so production threads
80/// `SystemRng` and tests inject `MockRng` for reproducible runs.
81/// Calling `Uuid::new_v4()` directly bypasses the injection; a CI guard
82/// (PG-072 in the platform ROADMAP) flags any new `Uuid::new_v4()`
83/// introductions outside this crate. Direct use of `mint_v4` outside
84/// its typed-id callers is similarly suspect; consider declaring a new
85/// typed id via [`define_id!`](crate::define_id!) instead.
86pub fn mint_v4<R: axess_rng::SecureRng>(rng: &R) -> Uuid {
87 let mut bytes = [0u8; 16];
88 rng.fill_bytes(&mut bytes);
89 uuid::Builder::from_random_bytes(bytes).into_uuid()
90}
91
92// ── Thread-local RNG override (PG-072) ──────────────────────────────────────
93
94type RngFiller = Box<dyn FnMut(&mut [u8])>;
95
96std::thread_local! {
97 static THREAD_LOCAL_RNG: std::cell::RefCell<Option<RngFiller>>
98 = const { std::cell::RefCell::new(None) };
99}
100
101/// Mint a fresh UUID v4 from the **default** RNG source, no caller-side
102/// `R: SecureRng` plumbing required.
103///
104/// Production behaviour: pulls bytes from [`axess_rng::SystemRng`] (the
105/// OS-provided CSPRNG via `rand::rng()`).
106///
107/// DST behaviour: when [`with_thread_local_rng`] is active on the current
108/// thread, the supplied [`SecureRng`](axess_rng::SecureRng) is used instead.
109/// Tests wrap the section of work that mints identity in
110/// `with_thread_local_rng(MockRng::new(seed), || { ... })` to make minting
111/// reproducible across runs.
112///
113/// **Why this exists** (PG-072 in the platform ROADMAP): the typed
114/// `FooId::new(&rng)` constructor is the DST-correct mint path, but
115/// adopting it requires every minting call site to thread an
116/// `R: SecureRng` parameter through every function signature in its
117/// call stack. The platform has hundreds of direct `Uuid::new_v4()`
118/// callers spread across services, handlers, and repos; threading RNG
119/// through all of them at once is a prohibitively invasive sweep.
120/// `mint_v4_default()` is the pragmatic alternative: a no-arg helper
121/// that honours the DST contract via a thread-local override, and
122/// otherwise falls back to the OS RNG. Tests opt in per-thread;
123/// production code is unaffected.
124///
125/// # Panics
126///
127/// On `wasm32-unknown-unknown` without a thread-local override installed,
128/// since `SystemRng` is not available there. Either install an override or
129/// switch to the typed `FooId::new(&rng)` API on that target.
130pub fn mint_v4_default() -> Uuid {
131 let mut bytes = [0u8; 16];
132 THREAD_LOCAL_RNG.with(|cell| {
133 let mut opt = cell.borrow_mut();
134 if let Some(filler) = opt.as_mut() {
135 filler(&mut bytes);
136 } else {
137 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
138 {
139 use axess_rng::SecureRng;
140 axess_rng::SystemRng.fill_bytes(&mut bytes);
141 }
142 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
143 {
144 panic!(
145 "mint_v4_default() on wasm32-unknown-unknown requires a thread-local RNG \
146 override (see with_thread_local_rng); the OS RNG is not available on that target."
147 );
148 }
149 }
150 });
151 uuid::Builder::from_random_bytes(bytes).into_uuid()
152}
153
154/// Install a thread-local RNG override for the duration of `f`. Restores
155/// the previous override (if any) on return, including on panic, via a
156/// Drop guard.
157///
158/// Used by tests to make [`mint_v4_default`] (and any typed id minted via
159/// the no-arg `FooId::new_default()` helper, when added) deterministic.
160/// Production code must not call this; the override leaks RNG state
161/// across otherwise-unrelated work on the same thread, and the only
162/// sanctioned production RNG is [`axess_rng::SystemRng`].
163pub fn with_thread_local_rng<R, F, T>(rng: R, f: F) -> T
164where
165 R: axess_rng::SecureRng,
166 F: FnOnce() -> T,
167{
168 struct Guard(Option<RngFiller>);
169 impl Drop for Guard {
170 fn drop(&mut self) {
171 THREAD_LOCAL_RNG.with(|cell| {
172 *cell.borrow_mut() = self.0.take();
173 });
174 }
175 }
176
177 let prev = THREAD_LOCAL_RNG.with(|cell| {
178 cell.borrow_mut().replace(Box::new(move |dest: &mut [u8]| {
179 rng.fill_bytes(dest);
180 }))
181 });
182 let restore_guard = Guard(prev);
183 let result = f();
184 drop(restore_guard);
185 result
186}
187
188/// Declare a typed id newtype.
189///
190/// Expands to a `pub struct $name(uuid::Uuid)` newtype with the
191/// standard impls (Display, FromStr, `From<Uuid>`, optional serde
192/// transparent, optional rkyv) plus a DST-friendly
193/// `new<R: SecureRng>(rng) -> Self` constructor. See the crate-level
194/// docs for the full API surface.
195///
196/// # Visibility constraint
197///
198/// The macro accepts only `pub` newtypes; the matcher requires
199/// `pub $name`. This is deliberate: typed identity primitives are
200/// public API (they cross crate boundaries), and `pub(crate)` /
201/// `pub(super)` ids would couple the type to its local module without
202/// buying anything. If you need a crate-private id-shaped helper,
203/// prefer a tiny inline struct or a `pub` newtype with a `pub(crate)`
204/// constructor.
205///
206/// # Transitive deps required by the expansion
207///
208/// `define_id!` expands to code referring to [`axess_rng::SecureRng`]
209/// in the `Self::new` constructor's bound. Adopters need
210/// `axess-rng` as a direct dependency (and `uuid` for the
211/// underlying type). Adopters using the optional `serde` /
212/// `rkyv` features additionally need `serde` / `rkyv` as direct
213/// dependencies. axess-identity ships the smoke for these to work; the
214/// macro itself emits no version-specific algorithm calls beyond
215/// what `mint_v4` invokes.
216///
217/// ```
218/// use axess_identity::define_id;
219///
220/// define_id! {
221/// /// An account identifier.
222/// pub AccountId
223/// }
224///
225/// let rng = axess_rng::testing::MockRng::new(42);
226/// let id = AccountId::new(&rng);
227/// assert!(!id.is_nil());
228/// assert_eq!(id.as_uuid().get_version_num(), 4);
229/// ```
230#[macro_export]
231macro_rules! define_id {
232 ($(#[$meta:meta])* pub $name:ident) => {
233 $(#[$meta])*
234 ///
235 /// Backed by [`uuid::Uuid`] (16 bytes, `Copy`). UUID v4
236 /// random when minted via [`Self::new`]; v5 namespaced when
237 /// adopted from a non-UUID source via
238 /// [`Self::from_namespaced_str`]; bytes stored verbatim
239 /// when restored from persistence via [`Self::from_bytes`].
240 /// Wire format under serde is the hyphenated UUID string;
241 /// under rkyv it's the 16-byte archive layout.
242 #[cfg_attr(
243 feature = "serde",
244 derive(serde::Serialize, serde::Deserialize)
245 )]
246 #[cfg_attr(feature = "serde", serde(transparent))]
247 #[cfg_attr(
248 feature = "rkyv",
249 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
250 )]
251 // `Default` is intentionally **not** derived. `Default::default()`
252 // would yield `Self(Uuid::nil())` which collides with
253 // `TenantId::SYSTEM` / `UserId::SYSTEM` semantics: a developer
254 // writing `let tid: TenantId = Default::default();` to mean
255 // "placeholder" would silently produce the system-tenant
256 // sentinel. Use [`Self::NIL`] explicitly when an all-zero id is
257 // intended; use [`Self::new`] with an injected RNG when a fresh
258 // id is wanted; use [`Self::from_uuid`] when wrapping an
259 // externally-supplied Uuid.
260 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
261 pub struct $name($crate::Uuid);
262
263 impl $name {
264 /// All-zero sentinel. Equal to [`Uuid::nil`].
265 pub const NIL: Self = Self($crate::Uuid::nil());
266
267 /// Mint a fresh UUID v4 from the supplied
268 /// [`SecureRng`](axess_rng::SecureRng). Sets version 4 +
269 /// RFC 4122 variant bits per spec; the remaining 122
270 /// bits come from `rng`.
271 #[inline]
272 pub fn new<R: axess_rng::SecureRng>(rng: &R) -> Self {
273 Self($crate::mint_v4(rng))
274 }
275
276 #[doc = concat!(
277 "Construct from a string (which must be a valid hyphenated Uuid). ",
278 "Returns [`IdError::Empty`] for empty input or ",
279 "[`IdError::NotAUuid`] if parsing fails.",
280 )]
281 pub fn try_new(value: impl AsRef<str>) -> ::std::result::Result<Self, $crate::IdError> {
282 let s = value.as_ref();
283 if s.is_empty() {
284 return Err($crate::IdError::Empty(stringify!($name)));
285 }
286 $crate::Uuid::parse_str(s)
287 .map(Self)
288 .map_err(|_| $crate::IdError::NotAUuid(stringify!($name)))
289 }
290
291 /// Wrap an existing [`Uuid`].
292 #[inline]
293 pub const fn from_uuid(uuid: $crate::Uuid) -> Self {
294 Self(uuid)
295 }
296
297 /// Construct from raw bytes verbatim (version and variant
298 /// bits are not adjusted). For round-tripping persisted ids
299 /// whose bytes already encode a valid Uuid.
300 #[inline]
301 pub const fn from_bytes(bytes: [u8; 16]) -> Self {
302 Self($crate::Uuid::from_bytes(bytes))
303 }
304
305 /// Construct a UUID v4-shaped id from 16 random bytes
306 /// (sets version + variant bits per RFC 4122).
307 ///
308 /// **Prefer [`Self::new`].** This constructor is for the
309 /// niche case where 16 random bytes have already been
310 /// drawn (e.g. from a fixed test seed buffer or an
311 /// external CSPRNG that doesn't expose
312 /// [`SecureRng`](axess_rng::SecureRng)). When you have a
313 /// `SecureRng` in scope (production code always does,
314 /// tests should), `Self::new(&mut rng)` is the DST-correct
315 /// path: the only random source is the injected RNG, so
316 /// tests that mint identity remain reproducible across
317 /// runs.
318 #[inline]
319 pub fn from_random_bytes(bytes: [u8; 16]) -> Self {
320 Self(uuid::Builder::from_random_bytes(bytes).into_uuid())
321 }
322
323 /// Map a non-UUID adopter identifier (slug, OAuth subject,
324 /// integer-stringified, ...) to a stable id via UUID v5.
325 /// Same `(namespace, name)` always produces the same id, so
326 /// services agree without coordination.
327 #[inline]
328 pub fn from_namespaced_str(namespace: $crate::Uuid, name: &str) -> Self {
329 Self($crate::Uuid::new_v5(&namespace, name.as_bytes()))
330 }
331
332 /// Borrow the raw 16-byte body. Zero-cost handoff to
333 /// byte-shaped APIs.
334 #[inline]
335 pub const fn as_bytes(&self) -> &[u8; 16] {
336 self.0.as_bytes()
337 }
338
339 /// Get the underlying [`Uuid`]. Zero-cost conversion to any
340 /// sibling newtype (`other_crate::FooId::from_uuid(id.as_uuid())`).
341 #[inline]
342 pub const fn as_uuid(&self) -> $crate::Uuid {
343 self.0
344 }
345
346 /// `true` when this is the all-zero ([`Uuid::nil`])
347 /// sentinel.
348 #[inline]
349 pub fn is_nil(&self) -> bool {
350 self.0.is_nil()
351 }
352 }
353
354 impl ::std::fmt::Display for $name {
355 fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
356 ::std::fmt::Display::fmt(&self.0, f)
357 }
358 }
359
360 impl ::std::convert::From<$crate::Uuid> for $name {
361 #[inline]
362 fn from(uuid: $crate::Uuid) -> Self {
363 Self(uuid)
364 }
365 }
366
367 impl ::std::convert::From<$name> for $crate::Uuid {
368 #[inline]
369 fn from(id: $name) -> Self {
370 id.0
371 }
372 }
373
374 impl ::std::convert::From<[u8; 16]> for $name {
375 #[inline]
376 fn from(bytes: [u8; 16]) -> Self {
377 Self($crate::Uuid::from_bytes(bytes))
378 }
379 }
380
381 impl ::std::str::FromStr for $name {
382 type Err = $crate::IdError;
383 fn from_str(s: &str) -> ::std::result::Result<Self, Self::Err> {
384 Self::try_new(s)
385 }
386 }
387
388 // sqlx integration. Delegates to `String` so the wire format
389 // matches the platform schema's TEXT-typed UUID columns. The cost
390 // is one heap allocation per bind, equivalent to writing
391 // `.bind(typed_id.to_string())` by hand; the gain is type-safe
392 // binds (`.bind(&typed_id)`) and a forward-compat lever; flipping
393 // the feature body to `uuid/sqlx` would route through the zero-copy
394 // native UUID path (BLOB(16) on sqlite, native UUID on postgres)
395 // once the schema migrates.
396 #[cfg(feature = "sqlx")]
397 impl<DB> ::sqlx::Type<DB> for $name
398 where
399 DB: ::sqlx::Database,
400 ::std::string::String: ::sqlx::Type<DB>,
401 {
402 fn type_info() -> <DB as ::sqlx::Database>::TypeInfo {
403 <::std::string::String as ::sqlx::Type<DB>>::type_info()
404 }
405
406 fn compatible(ty: &<DB as ::sqlx::Database>::TypeInfo) -> bool {
407 <::std::string::String as ::sqlx::Type<DB>>::compatible(ty)
408 }
409 }
410
411 #[cfg(feature = "sqlx")]
412 impl<'q, DB> ::sqlx::Encode<'q, DB> for $name
413 where
414 DB: ::sqlx::Database,
415 ::std::string::String: ::sqlx::Encode<'q, DB>,
416 {
417 fn encode_by_ref(
418 &self,
419 buf: &mut <DB as ::sqlx::Database>::ArgumentBuffer,
420 ) -> ::std::result::Result<
421 ::sqlx::encode::IsNull,
422 ::sqlx::error::BoxDynError,
423 > {
424 <::std::string::String as ::sqlx::Encode<'q, DB>>::encode_by_ref(
425 &self.0.to_string(),
426 buf,
427 )
428 }
429 }
430
431 #[cfg(feature = "sqlx")]
432 impl<'r, DB> ::sqlx::Decode<'r, DB> for $name
433 where
434 DB: ::sqlx::Database,
435 ::std::string::String: ::sqlx::Decode<'r, DB>,
436 {
437 fn decode(
438 value: <DB as ::sqlx::Database>::ValueRef<'r>,
439 ) -> ::std::result::Result<Self, ::sqlx::error::BoxDynError> {
440 let s = <::std::string::String as ::sqlx::Decode<'r, DB>>::decode(value)?;
441 Self::try_new(s).map_err(::std::convert::Into::into)
442 }
443 }
444 };
445}
446
447// ── Standard axess identity types ───────────────────────────────────────────
448
449define_id! {
450 /// Tenant identifier. Scopes principals and events to a
451 /// multi-tenant boundary. Adopter-supplied: see
452 /// [`Self::from_uuid`] for direct UUID adoption,
453 /// [`Self::from_namespaced_str`] for v5 mapping of non-UUID
454 /// identifiers, [`Self::SYSTEM`] for the reserved platform-operator
455 /// sentinel.
456 pub TenantId
457}
458
459impl TenantId {
460 /// String form of the reserved system-tenant identifier
461 /// (`"00000000-0000-0000-0000-000000000000"`). Application
462 /// storage should install a real `tenants` row with this id so
463 /// foreign-key references from tenant-scoped tables remain
464 /// intact.
465 pub const SYSTEM_STR: &'static str = "00000000-0000-0000-0000-000000000000";
466
467 /// The reserved system-tenant sentinel, equal to [`Uuid::nil`].
468 pub const SYSTEM: Self = Self(Uuid::nil());
469
470 /// The reserved system-tenant identifier.
471 #[inline]
472 pub const fn system() -> Self {
473 Self::SYSTEM
474 }
475
476 /// `true` when this identifier names the system tenant.
477 #[inline]
478 pub fn is_system(&self) -> bool {
479 self.is_nil()
480 }
481}
482
483define_id! {
484 /// User (subject / principal) identifier. Adopter-supplied, same
485 /// constructor surface as [`TenantId`]. Distinct [`Self::SYSTEM`]
486 /// sentinel from the tenant so applications installing real rows
487 /// for both don't collapse them.
488 pub UserId
489}
490
491impl UserId {
492 /// String form of the reserved system-user identifier
493 /// (`"00000000-0000-0000-0000-000000000001"`). Distinct from
494 /// [`TenantId::SYSTEM_STR`] so that an application installing
495 /// real rows for both doesn't collapse the system tenant and
496 /// the system user onto the same identifier.
497 pub const SYSTEM_STR: &'static str = "00000000-0000-0000-0000-000000000001";
498
499 /// The reserved system-user sentinel: non-nil, distinct from
500 /// [`TenantId::SYSTEM`].
501 pub const SYSTEM: Self = Self(Uuid::from_bytes([
502 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
503 ]));
504
505 /// The reserved system-user identifier.
506 #[inline]
507 pub const fn system() -> Self {
508 Self::SYSTEM
509 }
510
511 /// `true` when this identifier names the system user.
512 #[inline]
513 pub fn is_system(&self) -> bool {
514 *self == Self::SYSTEM
515 }
516}
517
518define_id! {
519 /// Device identifier, sibling to [`UserId`] / [`TenantId`].
520 /// axess-minted: cryptographic opacity is the security contract, so
521 /// prefer [`Self::new`] over [`Self::from_namespaced_str`] in
522 /// production paths.
523 pub DeviceId
524}
525
526define_id! {
527 /// Session identifier. axess-minted; cryptographic opacity is the
528 /// security contract. A session-id leak that revealed login time
529 /// would be a vulnerability for forensic correlation against
530 /// externally-observed events, so prefer [`Self::new`] (UUID v4
531 /// random) over time-prefixed variants.
532 ///
533 /// # Logging discipline
534 ///
535 /// `SessionId` derives [`std::fmt::Debug`] and [`std::fmt::Display`]
536 /// via [`define_id!`](crate::define_id!); both formats produce the
537 /// full hyphenated UUID string. **Treat session ids as credentials
538 /// in logs**: a `SessionId` that lands in a structured-log line, an
539 /// observability pipeline, or a crash dump leaks a still-valid
540 /// authenticator. Redact at the emission boundary (a project-local
541 /// `RedactedSessionId(SessionId)` newtype, or manual masking of
542 /// middle bytes). `axess-identity` does not redact at the type
543 /// level because the full id form is needed at the storage and
544 /// authn-validation boundaries.
545 pub SessionId
546}
547
548define_id! {
549 /// Event identifier for [`Event<P>`](https://docs.rs/axess-events)
550 /// envelopes. axess-minted: UUID v4 random from a DST-injected
551 /// [`SecureRng`](axess_rng::SecureRng). Sortability of events comes
552 /// from `Event::time_micros` and from domain time-stamped fields,
553 /// not from the id.
554 pub EventId
555}
556
557/// Returns `Err(IdError::Reserved)` when `id` matches
558/// [`UserId::SYSTEM`] or `tenant_id` matches [`TenantId::SYSTEM`].
559/// Useful as a guard at the top of `IdentityStore::create_user`
560/// implementations to refuse self-service signup with reserved
561/// platform-operator identifiers.
562pub fn ensure_user_id_not_reserved(user_id: &UserId, tenant_id: &TenantId) -> Result<(), IdError> {
563 if user_id.is_system() {
564 return Err(IdError::Reserved("UserId"));
565 }
566 if tenant_id.is_system() {
567 return Err(IdError::Reserved("TenantId"));
568 }
569 Ok(())
570}
571
572// Test-fixture helpers (`tenant(label)`, `user(label)`, ...) for the typed
573// ids live in [`crate::testing`] alongside [`crate::testing::MockResolver`];
574// see that module for usage.
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::testing;
580 use axess_rng::testing::MockRng;
581
582 #[test]
583 fn system_tenant_is_nil() {
584 assert!(TenantId::SYSTEM.is_system());
585 assert!(TenantId::system().is_nil());
586 assert_eq!(TenantId::SYSTEM.to_string(), TenantId::SYSTEM_STR);
587 }
588
589 #[test]
590 fn system_user_distinct_from_system_tenant() {
591 assert_ne!(UserId::SYSTEM.as_uuid(), TenantId::SYSTEM.as_uuid());
592 assert!(UserId::SYSTEM.is_system());
593 assert_eq!(UserId::SYSTEM.to_string(), UserId::SYSTEM_STR);
594 }
595
596 #[test]
597 fn try_new_rejects_empty() {
598 assert_eq!(TenantId::try_new(""), Err(IdError::Empty("TenantId")));
599 assert_eq!(UserId::try_new(""), Err(IdError::Empty("UserId")));
600 }
601
602 #[test]
603 fn try_new_rejects_non_uuid() {
604 assert_eq!(
605 TenantId::try_new("not-a-uuid"),
606 Err(IdError::NotAUuid("TenantId"))
607 );
608 }
609
610 #[test]
611 fn try_new_accepts_uuid_string() {
612 let t = TenantId::try_new("1f0a7b2e-4c91-4e3f-9b2a-8d0123456789").unwrap();
613 assert!(!t.is_system());
614 }
615
616 #[test]
617 fn new_is_dst_reproducible() {
618 let a = MockRng::new(42);
619 let b = MockRng::new(42);
620 assert_eq!(TenantId::new(&a), TenantId::new(&b));
621 assert_eq!(
622 SessionId::new(&MockRng::new(7)).as_uuid().get_version_num(),
623 4
624 );
625 }
626
627 #[test]
628 fn from_namespaced_str_is_deterministic() {
629 let ns = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
630 let a = TenantId::from_namespaced_str(ns, "ekekrantz");
631 let b = TenantId::from_namespaced_str(ns, "ekekrantz");
632 let c = TenantId::from_namespaced_str(ns, "wctest");
633 assert_eq!(a, b);
634 assert_ne!(a, c);
635 assert_eq!(a.as_uuid().get_version_num(), 5);
636 }
637
638 #[test]
639 fn ensure_user_id_not_reserved_blocks_system_user() {
640 let res = ensure_user_id_not_reserved(&UserId::SYSTEM, &testing::tenant("t1"));
641 assert_eq!(res, Err(IdError::Reserved("UserId")));
642 }
643
644 #[test]
645 fn ensure_user_id_not_reserved_blocks_system_tenant() {
646 let res = ensure_user_id_not_reserved(&testing::user("u1"), &TenantId::SYSTEM);
647 assert_eq!(res, Err(IdError::Reserved("TenantId")));
648 }
649
650 #[test]
651 fn ensure_user_id_not_reserved_accepts_normal_pair() {
652 let res = ensure_user_id_not_reserved(&testing::user("u1"), &testing::tenant("t1"));
653 assert!(res.is_ok());
654 }
655
656 #[test]
657 fn testing_helpers_are_deterministic() {
658 assert_eq!(testing::tenant("alice"), testing::tenant("alice"));
659 assert_eq!(testing::user("alice"), testing::user("alice"));
660 assert_eq!(testing::device("alice"), testing::device("alice"));
661 assert_eq!(testing::session("alice"), testing::session("alice"));
662 assert_eq!(testing::event("alice"), testing::event("alice"));
663 }
664
665 #[cfg(feature = "serde")]
666 #[test]
667 fn serde_wire_is_hyphenated_string() {
668 let id =
669 TenantId::from_uuid(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap());
670 let json = serde_json::to_string(&id).unwrap();
671 assert_eq!(json, "\"550e8400-e29b-41d4-a716-446655440000\"");
672 let back: TenantId = serde_json::from_str(&json).unwrap();
673 assert_eq!(id, back);
674 }
675
676 #[cfg(feature = "serde")]
677 #[test]
678 fn id_error_serialise_shape_is_stable() {
679 // Pin the IdError JSON shape so error responses crossing the
680 // wire (HTTP boundary, SIEM pipelines) stay stable. The
681 // discriminator is the variant name; the body is the
682 // `&'static str` kind label.
683 //
684 // Round-trip is intentionally one-way (serialise only): the
685 // `&'static str` payload can't be reconstituted from
686 // non-static input, so `Deserialize` is supported only for
687 // structural symmetry (e.g. a higher-level error type that
688 // happens to embed `IdError` and decodes from a known
689 // source). External-input deserialisation should land in a
690 // typed-error wrapper that owns its strings, not in
691 // `IdError` directly.
692 for (err, expected_json) in [
693 (IdError::Empty("TenantId"), r#"{"Empty":"TenantId"}"#),
694 (IdError::NotAUuid("UserId"), r#"{"NotAUuid":"UserId"}"#),
695 (IdError::Reserved("DeviceId"), r#"{"Reserved":"DeviceId"}"#),
696 ] {
697 let json = serde_json::to_string(&err).unwrap();
698 assert_eq!(json, expected_json, "IdError JSON shape drifted");
699 }
700 }
701
702 #[test]
703 fn no_default_impl() {
704 // Pin the absence of `Default` on typed ids; `Default::default()`
705 // would silently produce `Self(Uuid::nil())`, which collides
706 // with `TenantId::SYSTEM` / `UserId::SYSTEM` semantics. Forcing
707 // explicit construction (via `NIL`, `new(rng)`, `from_uuid`,
708 // or `from_namespaced_str`) makes intent explicit at every
709 // call site.
710 //
711 // This compile-time test asserts the absence by checking that
712 // `TenantId` does NOT implement `Default`. If `Default` is
713 // ever re-added, this test won't compile.
714 fn assert_not_default<T>()
715 where
716 T: Sized,
717 {
718 }
719 assert_not_default::<TenantId>();
720 assert_not_default::<UserId>();
721 assert_not_default::<SessionId>();
722 assert_not_default::<DeviceId>();
723 assert_not_default::<EventId>();
724 // To be a useful pin: this would ideally check
725 // `!impls Default`, but Rust's negative-bound expressivity
726 // is limited. The signal is in the line above; call sites
727 // that do `TenantId::default()` will fail at use, not here.
728 }
729
730 define_id! {
731 /// Adopter-defined id used in macro tests.
732 pub TestId
733 }
734
735 #[test]
736 fn define_id_macro_yields_v4_via_new() {
737 let rng = MockRng::new(7);
738 let id = TestId::new(&rng);
739 assert_eq!(id.as_uuid().get_version_num(), 4);
740 assert!(!id.is_nil());
741 }
742
743 /// PG-072: `mint_v4_default()` honours the thread-local RNG override.
744 /// Same seed via `with_thread_local_rng` produces the same Uuid both
745 /// times, confirming DST reproducibility through the no-arg helper.
746 #[test]
747 fn mint_v4_default_is_dst_reproducible_under_override() {
748 let a = with_thread_local_rng(MockRng::new(99), mint_v4_default);
749 let b = with_thread_local_rng(MockRng::new(99), mint_v4_default);
750 assert_eq!(a, b);
751 // And: outside the override, a third draw uses SystemRng and is
752 // (with overwhelming probability) distinct.
753 let c = mint_v4_default();
754 assert_ne!(a, c);
755 assert_eq!(a.get_version_num(), 4);
756 assert_eq!(b.get_version_num(), 4);
757 assert_eq!(c.get_version_num(), 4);
758 }
759
760 /// sqlx round-trip: bind a typed id into a TEXT column, query it
761 /// back, decode into the typed shape, verify the value matches.
762 /// Catches both Encode (TEXT-shaped output) and Decode (TEXT input
763 /// to `try_new` parsing) regressions in one go.
764 #[cfg(feature = "sqlx")]
765 #[tokio::test]
766 async fn sqlx_text_column_roundtrip() {
767 use sqlx::sqlite::SqlitePoolOptions;
768
769 let pool = SqlitePoolOptions::new()
770 .max_connections(1)
771 .connect("sqlite::memory:")
772 .await
773 .unwrap();
774
775 sqlx::query("CREATE TABLE ids (id TEXT NOT NULL PRIMARY KEY)")
776 .execute(&pool)
777 .await
778 .unwrap();
779
780 let rng = MockRng::new(13);
781 let original = TenantId::new(&rng);
782
783 sqlx::query("INSERT INTO ids (id) VALUES (?1)")
784 .bind(original)
785 .execute(&pool)
786 .await
787 .unwrap();
788
789 let row: (TenantId,) = sqlx::query_as("SELECT id FROM ids LIMIT 1")
790 .fetch_one(&pool)
791 .await
792 .unwrap();
793
794 assert_eq!(row.0, original);
795 }
796
797 #[test]
798 fn id_error_display_pins_each_variant_string() {
799 assert_eq!(IdError::Empty("Foo").to_string(), "Foo must be non-empty");
800 assert_eq!(
801 IdError::NotAUuid("Bar").to_string(),
802 "Bar must be a valid Uuid"
803 );
804 assert_eq!(
805 IdError::Reserved("Baz").to_string(),
806 "Baz matches reserved system identifier"
807 );
808 }
809
810 /// The Drop impl on `with_thread_local_rng`'s `Guard` restores
811 /// the previous thread-local override on scope exit. With the
812 /// Drop body deleted (mutation), the inner override would leak
813 /// into the outer scope, so a re-mint after the inner closure
814 /// returns would observe the inner seed's draws instead of the
815 /// outer seed's. Pin the restoration by comparing draws on both
816 /// sides of the inner scope.
817 #[test]
818 fn with_thread_local_rng_drop_restores_previous_override() {
819 // What the outer seed would draw at step 1 with NO inner
820 // disturbance.
821 let outer_step1 = with_thread_local_rng(MockRng::new(1), mint_v4_default);
822
823 // Same outer seed, but with an inner override that drains
824 // five draws (different seed). If the Drop on Guard restores
825 // the outer override, the next draw after the inner scope
826 // must equal `outer_step1` (the FIRST draw on a fresh seed=1
827 // override). If Drop is a no-op (mutation), the next draw
828 // comes from seed=99 instead.
829 let post_inner = with_thread_local_rng(MockRng::new(1), || {
830 with_thread_local_rng(MockRng::new(99), || {
831 for _ in 0..5 {
832 let _ = mint_v4_default();
833 }
834 });
835 mint_v4_default()
836 });
837
838 assert_eq!(
839 outer_step1, post_inner,
840 "Drop on Guard MUST restore the outer override; \
841 otherwise the inner override leaks across the scope boundary"
842 );
843 }
844}