Skip to main content

api_bones/
audit.rs

1//! Audit metadata for API resources.
2//!
3//! Provides [`AuditInfo`], an embeddable struct that tracks when a resource
4//! was created and last updated, and by whom, plus [`Principal`] — the
5//! canonical actor-identity newtype threaded through audit events across
6//! services.
7//!
8//! # Standards
9//! - Timestamps: [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339)
10
11use crate::common::Timestamp;
12#[cfg(feature = "uuid")]
13use crate::org_id::OrgId;
14#[cfg(all(not(feature = "std"), feature = "alloc"))]
15use alloc::borrow::Cow;
16#[cfg(all(not(feature = "std"), feature = "alloc", feature = "uuid"))]
17use alloc::borrow::ToOwned;
18#[cfg(all(not(feature = "std"), feature = "alloc"))]
19use alloc::string::String;
20#[cfg(all(not(feature = "std"), feature = "alloc", feature = "uuid"))]
21use alloc::string::ToString;
22#[cfg(all(not(feature = "std"), feature = "alloc", feature = "uuid"))]
23use alloc::vec::Vec;
24
25#[cfg(feature = "std")]
26use std::borrow::Cow;
27
28#[cfg(feature = "uuid")]
29use uuid::Uuid;
30
31#[cfg(feature = "serde")]
32use serde::{Deserialize, Serialize};
33
34// ---------------------------------------------------------------------------
35// PrincipalId
36// ---------------------------------------------------------------------------
37
38/// Opaque principal identifier. Flexible string storage:
39/// UUID strings for User principals, static service names for System/Service.
40#[derive(Clone, PartialEq, Eq, Hash)]
41#[cfg_attr(feature = "serde", derive(Serialize, Deserialize), serde(transparent))]
42#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
43#[cfg_attr(feature = "utoipa", schema(value_type = String))]
44#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
45#[cfg_attr(feature = "schemars", schemars(transparent))]
46pub struct PrincipalId(Cow<'static, str>);
47
48impl PrincipalId {
49    /// Wrap a `&'static str` — for compile-time system/service names.
50    #[must_use]
51    pub const fn static_str(s: &'static str) -> Self {
52        Self(Cow::Borrowed(s))
53    }
54
55    /// Wrap an owned String — for DB round-trips and dynamic construction.
56    #[must_use]
57    pub fn from_owned(s: String) -> Self {
58        Self(Cow::Owned(s))
59    }
60
61    /// Borrow the id as `&str`.
62    #[must_use]
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66
67    /// Construct from a UUID (stores as hyphenated string).
68    #[cfg(feature = "uuid")]
69    #[must_use]
70    pub fn from_uuid(uuid: Uuid) -> Self {
71        Self(Cow::Owned(uuid.to_string()))
72    }
73}
74
75impl core::fmt::Debug for PrincipalId {
76    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
77        f.debug_tuple("PrincipalId").field(&self.as_str()).finish()
78    }
79}
80
81impl core::fmt::Display for PrincipalId {
82    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
83        f.write_str(self.as_str())
84    }
85}
86
87/// Discriminator for the kind of actor a Principal represents.
88#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
89#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
90#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
91#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
92#[non_exhaustive]
93pub enum PrincipalKind {
94    /// Human or end-user identity (id is a UUID string).
95    User,
96    /// Autonomous service-to-service identity (id is a service name).
97    Service,
98    /// Platform-level system actor (id is a static name, may be outside any org).
99    System,
100    /// Hardware-bound identity for `IoT`, edge nodes, or TPM/Secure-Enclave-backed
101    /// devices (id is a UUID string, typically derived at provisioning).
102    /// Distinct from `Service` because identity is pinned to physical hardware,
103    /// not to a deployed software service.
104    Device,
105    /// Autonomous AI / automation principal with constrained scope (id is a
106    /// stable agent name, e.g. `"ops.triage-agent"`). Distinct from `Service`
107    /// because credential policy treats agents differently — typically shorter
108    /// credential TTLs and smaller default scope.
109    Agent,
110}
111
112// ---------------------------------------------------------------------------
113// DeviceLease
114// ---------------------------------------------------------------------------
115
116/// Discriminates the rate-limit accounting model for a [`PrincipalKind::Device`]
117/// principal.
118///
119/// `distributed-ratelimit` consults this to decide whether to track Device
120/// principals as a connection gauge or a time-windowed request counter.
121#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
122#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
123#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
124#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
125pub enum DeviceLeaseKind {
126    /// Concurrent-connection gauge.  The ratelimit counter is decremented
127    /// on disconnect; `DeviceLease::max_concurrent` caps simultaneous
128    /// connections from this device.
129    Connection,
130    /// Time-windowed request counter.  Same accounting shape as `User` /
131    /// `Service`; `DeviceLease::max_concurrent` is the burst ceiling per
132    /// window.
133    RequestStream,
134}
135
136/// Rate-limit lease contract for [`PrincipalKind::Device`] principals.
137///
138/// Carries the information `distributed-ratelimit` needs to implement the
139/// `Device` floor in `PrincipalKindPolicy` without re-deriving semantics.
140///
141/// # Refresh bound
142///
143/// `refresh` is the hardware-attestation renewal cadence and sets the upper
144/// bound on the rate-limit TTL floor for this device.  It **must not exceed
145/// 3 600 seconds** (1 h), consistent with the Agent credential hard-cap in
146/// platform ADR 0012.  The definitive value will be pinned when quorumauth#25
147/// (Device/Agent attestation split) settles; until then callers should treat
148/// 3 600 s as the ceiling.
149#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
150#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
151#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
152#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
153pub struct DeviceLease {
154    /// Whether to track this device as a connection gauge or a request
155    /// counter.
156    pub kind: DeviceLeaseKind,
157    /// Maximum concurrent connections (`Connection` kind) or maximum burst
158    /// requests per window (`RequestStream` kind).  `None` means no
159    /// device-specific cap; the extractor falls back to the global default.
160    pub max_concurrent: Option<u32>,
161    /// Hardware-attestation refresh cadence.  Sets the upper bound on the
162    /// rate-limit TTL floor.  Must not exceed 3 600 s (see doc comment).
163    pub refresh_secs: u32,
164}
165
166impl DeviceLease {
167    /// Maximum permitted `refresh_secs` value (1 h, matching platform ADR
168    /// 0012 Agent credential hard-cap and acting as a proxy for the
169    /// quorumauth#25 attestation cadence until that ADR settles).
170    pub const MAX_REFRESH_SECS: u32 = 3_600;
171
172    /// Construct a `DeviceLease`, clamping `refresh_secs` to
173    /// [`Self::MAX_REFRESH_SECS`].
174    ///
175    /// # Examples
176    ///
177    /// ```rust
178    /// use api_bones::{DeviceLease, DeviceLeaseKind};
179    ///
180    /// // Connection-count gauge, max 10 simultaneous, 15-minute refresh.
181    /// let lease = DeviceLease::new(DeviceLeaseKind::Connection, Some(10), 900);
182    /// assert_eq!(lease.refresh_secs, 900);
183    ///
184    /// // Refresh value is clamped to MAX_REFRESH_SECS (3 600 s).
185    /// let capped = DeviceLease::new(DeviceLeaseKind::RequestStream, None, 99_999);
186    /// assert_eq!(capped.refresh_secs, DeviceLease::MAX_REFRESH_SECS);
187    /// ```
188    #[must_use]
189    pub fn new(kind: DeviceLeaseKind, max_concurrent: Option<u32>, refresh_secs: u32) -> Self {
190        Self {
191            kind,
192            max_concurrent,
193            refresh_secs: refresh_secs.min(Self::MAX_REFRESH_SECS),
194        }
195    }
196}
197
198// ---------------------------------------------------------------------------
199// PrincipalParseError
200// ---------------------------------------------------------------------------
201
202/// Error returned by [`Principal::try_parse`] when the input is not a valid
203/// UUID string.
204///
205/// Wraps the offending input so callers can surface it in diagnostics.
206/// The value is included in both `Display` and `Debug` output; callers must
207/// not log this in contexts where the input might contain PII.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct PrincipalParseError {
210    /// The string that failed UUID parsing.
211    pub input: String,
212}
213
214impl core::fmt::Display for PrincipalParseError {
215    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
216        write!(
217            f,
218            "invalid Principal: expected a UUID string, got {:?}",
219            self.input
220        )
221    }
222}
223
224#[cfg(feature = "std")]
225impl std::error::Error for PrincipalParseError {}
226
227// ---------------------------------------------------------------------------
228// Principal
229// ---------------------------------------------------------------------------
230
231/// Canonical actor identity. Carries id, kind, and org-tree position.
232///
233/// Thread the *same* `Principal` through every downstream audit-emitting
234/// service instead of forking local newtypes.
235///
236/// `org_path` is root-to-self inclusive. Platform-internal actors outside
237/// any org tree use `org_path: vec![]`.
238///
239/// # Construction
240///
241/// - [`Principal::human`] — for human / end-user identities. Accepts a
242///   [`uuid::Uuid`] to prevent PII (emails, display names) from entering
243///   audit logs. Requires the `uuid` feature.
244/// - [`Principal::try_parse`] — parse a UUID string into a `Principal`.
245///   Returns [`PrincipalParseError`] for non-UUID input. Requires `uuid`.
246/// - [`Principal::system`] — for autonomous or system actors. Infallible but
247///   no longer `const` due to `Vec` in `org_path`.
248///
249/// # Semantics
250///
251/// Identity-only. `Principal` carries **no authorization semantics**: it
252/// names an actor, nothing more. JWT/OIDC parsing, scope checks, and
253/// permission resolution all belong in caller layers.
254///
255/// Principals are **not secrets** — `Debug` is *not* redacted, to preserve
256/// visibility in audit logs and tracing output.
257///
258/// # Examples
259///
260/// ```rust
261/// # #[cfg(feature = "uuid")] {
262/// use api_bones::Principal;
263/// use uuid::Uuid;
264///
265/// // Human principal — UUID only, no emails or display names
266/// let id = Uuid::new_v4();
267/// let alice = Principal::human(id);
268/// assert_eq!(alice.as_str(), id.to_string().as_str());
269///
270/// // System principal
271/// let rotation = Principal::system("billing.rotation-engine");
272/// assert_eq!(rotation.as_str(), "billing.rotation-engine");
273/// # }
274/// ```
275#[derive(Clone, PartialEq, Eq, Hash, Debug)]
276#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
277#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
278#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
279pub struct Principal {
280    /// The opaque principal identifier.
281    pub id: PrincipalId,
282    /// The kind of actor this principal represents.
283    pub kind: PrincipalKind,
284    /// Org path from root to the acting org (inclusive). Empty = platform scope.
285    /// Only present when the `uuid` feature is enabled.
286    #[cfg(feature = "uuid")]
287    #[cfg_attr(feature = "serde", serde(default))]
288    pub org_path: Vec<OrgId>,
289}
290
291impl Principal {
292    /// Construct a principal for a human actor from a [`uuid::Uuid`].
293    ///
294    /// This is the correct constructor for end-user / operator identities.
295    /// By requiring a `Uuid` the API prevents callers from accidentally
296    /// passing emails, display names, or other PII that would propagate into
297    /// audit logs and OTEL spans (see issue #204).
298    ///
299    /// # Examples
300    ///
301    /// ```rust
302    /// # #[cfg(feature = "uuid")] {
303    /// use api_bones::Principal;
304    /// use uuid::Uuid;
305    ///
306    /// let id = Uuid::new_v4();
307    /// let p = Principal::human(id);
308    /// assert_eq!(p.as_str(), id.to_string().as_str());
309    /// # }
310    /// ```
311    #[cfg(feature = "uuid")]
312    #[must_use]
313    pub fn human(uuid: Uuid) -> Self {
314        Self {
315            id: PrincipalId::from_uuid(uuid),
316            kind: PrincipalKind::User,
317            #[cfg(feature = "uuid")]
318            org_path: Vec::new(),
319        }
320    }
321
322    /// Parse a UUID string into a `Principal`.
323    ///
324    /// Accepts any UUID text form that [`uuid::Uuid::parse_str`] recognises
325    /// (hyphenated, simple, URN, braced). Returns [`PrincipalParseError`] for
326    /// anything else, including emails and empty strings.
327    ///
328    /// # Errors
329    ///
330    /// Returns [`PrincipalParseError`] when `s` is not a valid UUID string.
331    ///
332    /// # Examples
333    ///
334    /// ```rust
335    /// # #[cfg(feature = "uuid")] {
336    /// use api_bones::Principal;
337    ///
338    /// let p = Principal::try_parse("550e8400-e29b-41d4-a716-446655440000").unwrap();
339    /// assert_eq!(p.as_str(), "550e8400-e29b-41d4-a716-446655440000");
340    ///
341    /// assert!(Principal::try_parse("alice@example.com").is_err());
342    /// # }
343    /// ```
344    #[cfg(feature = "uuid")]
345    pub fn try_parse(s: &str) -> Result<Self, PrincipalParseError> {
346        Uuid::parse_str(s)
347            .map(Self::human)
348            .map_err(|_| PrincipalParseError {
349                input: s.to_owned(),
350            })
351    }
352
353    /// Construct a principal for a hardware-bound device identity from a [`uuid::Uuid`].
354    ///
355    /// Use this for `IoT` devices, edge nodes, or TPM/Secure-Enclave-backed
356    /// identities. Like [`Self::human`], requires a [`uuid::Uuid`] so that
357    /// device labels and serial numbers cannot leak into audit logs.
358    ///
359    /// # Examples
360    ///
361    /// ```rust
362    /// # #[cfg(feature = "uuid")] {
363    /// use api_bones::{Principal, PrincipalKind};
364    /// use uuid::Uuid;
365    ///
366    /// let id = Uuid::new_v4();
367    /// let edge = Principal::device(id);
368    /// assert_eq!(edge.kind, PrincipalKind::Device);
369    /// # }
370    /// ```
371    #[cfg(feature = "uuid")]
372    #[must_use]
373    pub fn device(uuid: Uuid) -> Self {
374        Self {
375            id: PrincipalId::from_uuid(uuid),
376            kind: PrincipalKind::Device,
377            #[cfg(feature = "uuid")]
378            org_path: Vec::new(),
379        }
380    }
381
382    /// Construct an autonomous agent principal from a `&'static` string.
383    ///
384    /// Use this for AI / automation principals (LLM agents, scheduled bots)
385    /// whose identity is a stable, named role rather than a per-request UUID.
386    /// Mirrors [`Self::system`] in shape; differs in `kind` so credential
387    /// policy can apply agent-specific floors (shorter TTLs, smaller scope).
388    ///
389    /// # Examples
390    ///
391    /// ```rust
392    /// use api_bones::{Principal, PrincipalKind};
393    ///
394    /// let triage = Principal::agent("ops.triage-agent");
395    /// assert_eq!(triage.as_str(), "ops.triage-agent");
396    /// assert_eq!(triage.kind, PrincipalKind::Agent);
397    /// ```
398    #[must_use]
399    pub fn agent(id: &'static str) -> Self {
400        Self {
401            id: PrincipalId::static_str(id),
402            kind: PrincipalKind::Agent,
403            #[cfg(feature = "uuid")]
404            org_path: Vec::new(),
405        }
406    }
407
408    /// Construct a system principal from a `&'static` string.
409    ///
410    /// Infallible but no longer `const` since `org_path` is a `Vec`.
411    ///
412    /// # Examples
413    ///
414    /// ```rust
415    /// use api_bones::Principal;
416    ///
417    /// let bootstrap = Principal::system("orders.bootstrap");
418    /// assert_eq!(bootstrap.as_str(), "orders.bootstrap");
419    /// ```
420    #[must_use]
421    pub fn system(id: &'static str) -> Self {
422        Self {
423            id: PrincipalId::static_str(id),
424            kind: PrincipalKind::System,
425            #[cfg(feature = "uuid")]
426            org_path: Vec::new(),
427        }
428    }
429
430    /// Borrow the principal as a `&str`.
431    ///
432    /// # Examples
433    ///
434    /// ```rust
435    /// use api_bones::Principal;
436    ///
437    /// assert_eq!(Principal::system("bob").as_str(), "bob");
438    /// ```
439    #[must_use]
440    pub fn as_str(&self) -> &str {
441        self.id.as_str()
442    }
443
444    /// Set the org path on this principal (builder-style).
445    ///
446    /// # Examples
447    ///
448    /// ```rust
449    /// # #[cfg(feature = "uuid")] {
450    /// use api_bones::{Principal, OrgId};
451    /// use uuid::Uuid;
452    ///
453    /// let p = Principal::human(Uuid::nil())
454    ///     .with_org_path(vec![OrgId::generate()]);
455    /// assert!(!p.org_path.is_empty());
456    /// # }
457    /// ```
458    #[cfg(feature = "uuid")]
459    #[must_use]
460    pub fn with_org_path(mut self, org_path: Vec<OrgId>) -> Self {
461        self.org_path = org_path;
462        self
463    }
464
465    /// Returns the org ancestry path as a comma-separated UUID string.
466    ///
467    /// Produces `""` for platform-internal actors with no org affiliation,
468    /// and `"<uuid1>,<uuid2>,..."` (root-to-self) for org-scoped actors.
469    /// Intended for use as an OTEL span attribute value (`enduser.org_path`).
470    ///
471    /// ```
472    /// # #[cfg(feature = "uuid")] {
473    /// use api_bones::Principal;
474    ///
475    /// let p = Principal::system("svc");
476    /// assert_eq!(p.org_path_display(), "");
477    /// # }
478    /// ```
479    #[cfg(feature = "uuid")]
480    #[must_use]
481    pub fn org_path_display(&self) -> String {
482        self.org_path
483            .iter()
484            .map(ToString::to_string)
485            .collect::<Vec<_>>()
486            .join(",")
487    }
488}
489
490impl core::fmt::Display for Principal {
491    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
492        f.write_str(self.as_str())
493    }
494}
495
496/// When `uuid` is available, generate UUID-backed principals so the fuzzer
497/// never produces PII-shaped values (emails, display names, etc.).
498#[cfg(all(feature = "arbitrary", feature = "uuid"))]
499impl<'a> arbitrary::Arbitrary<'a> for Principal {
500    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
501        let bytes = <[u8; 16] as arbitrary::Arbitrary>::arbitrary(u)?;
502        Ok(Self::human(Uuid::from_bytes(bytes)))
503    }
504}
505
506/// Fallback when `uuid` feature is disabled: generate an arbitrary system principal.
507/// This path should rarely be reached in practice since `uuid` is in the
508/// default feature set.
509#[cfg(all(feature = "arbitrary", not(feature = "uuid")))]
510impl<'a> arbitrary::Arbitrary<'a> for Principal {
511    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
512        let s = <String as arbitrary::Arbitrary>::arbitrary(u)?;
513        Ok(Self {
514            id: PrincipalId::from_owned(s),
515            kind: PrincipalKind::System,
516            #[cfg(feature = "uuid")]
517            org_path: Vec::new(),
518        })
519    }
520}
521
522/// arbitrary impl for `PrincipalId`
523#[cfg(all(feature = "arbitrary", feature = "uuid"))]
524impl<'a> arbitrary::Arbitrary<'a> for PrincipalId {
525    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
526        let bytes = <[u8; 16] as arbitrary::Arbitrary>::arbitrary(u)?;
527        Ok(Self::from_uuid(Uuid::from_bytes(bytes)))
528    }
529}
530
531#[cfg(all(feature = "arbitrary", not(feature = "uuid")))]
532impl<'a> arbitrary::Arbitrary<'a> for PrincipalId {
533    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
534        let s = <String as arbitrary::Arbitrary>::arbitrary(u)?;
535        Ok(Self::from_owned(s))
536    }
537}
538
539/// arbitrary impl for `PrincipalKind`
540#[cfg(feature = "arbitrary")]
541impl<'a> arbitrary::Arbitrary<'a> for PrincipalKind {
542    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
543        match <u8 as arbitrary::Arbitrary>::arbitrary(u)? % 5 {
544            0 => Ok(Self::User),
545            1 => Ok(Self::Service),
546            2 => Ok(Self::System),
547            3 => Ok(Self::Device),
548            _ => Ok(Self::Agent),
549        }
550    }
551}
552
553/// When `uuid` is available, generate UUID-backed principals so proptest
554/// never generates PII-shaped values (emails, display names, etc.).
555#[cfg(all(feature = "proptest", feature = "uuid"))]
556impl proptest::arbitrary::Arbitrary for Principal {
557    type Parameters = ();
558    type Strategy = proptest::strategy::BoxedStrategy<Self>;
559
560    fn arbitrary_with((): ()) -> Self::Strategy {
561        use proptest::prelude::*;
562        any::<[u8; 16]>()
563            .prop_map(|b| Self::human(Uuid::from_bytes(b)))
564            .boxed()
565    }
566}
567
568/// Fallback when `uuid` feature is disabled.
569#[cfg(all(feature = "proptest", not(feature = "uuid")))]
570impl proptest::arbitrary::Arbitrary for Principal {
571    type Parameters = ();
572    type Strategy = proptest::strategy::BoxedStrategy<Self>;
573
574    fn arbitrary_with((): ()) -> Self::Strategy {
575        use proptest::prelude::*;
576        any::<String>()
577            .prop_map(|s| Self {
578                id: PrincipalId::from_owned(s),
579                kind: PrincipalKind::System,
580                #[cfg(feature = "uuid")]
581                org_path: Vec::new(),
582            })
583            .boxed()
584    }
585}
586
587/// proptest impl for `PrincipalId`
588#[cfg(all(feature = "proptest", feature = "uuid"))]
589impl proptest::arbitrary::Arbitrary for PrincipalId {
590    type Parameters = ();
591    type Strategy = proptest::strategy::BoxedStrategy<Self>;
592
593    fn arbitrary_with((): ()) -> Self::Strategy {
594        use proptest::prelude::*;
595        any::<[u8; 16]>()
596            .prop_map(|b| Self::from_uuid(Uuid::from_bytes(b)))
597            .boxed()
598    }
599}
600
601#[cfg(all(feature = "proptest", not(feature = "uuid")))]
602impl proptest::arbitrary::Arbitrary for PrincipalId {
603    type Parameters = ();
604    type Strategy = proptest::strategy::BoxedStrategy<Self>;
605
606    fn arbitrary_with((): ()) -> Self::Strategy {
607        use proptest::prelude::*;
608        any::<String>().prop_map(|s| Self::from_owned(s)).boxed()
609    }
610}
611
612/// proptest impl for `PrincipalKind`
613#[cfg(feature = "proptest")]
614impl proptest::arbitrary::Arbitrary for PrincipalKind {
615    type Parameters = ();
616    type Strategy = proptest::strategy::BoxedStrategy<Self>;
617
618    fn arbitrary_with((): ()) -> Self::Strategy {
619        use proptest::prelude::*;
620        prop_oneof![
621            Just(Self::User),
622            Just(Self::Service),
623            Just(Self::System),
624            Just(Self::Device),
625            Just(Self::Agent),
626        ]
627        .boxed()
628    }
629}
630
631// ---------------------------------------------------------------------------
632// AuditInfo
633// ---------------------------------------------------------------------------
634
635/// Audit metadata embedded in API resource structs.
636///
637/// Tracks creation and last-update times (RFC 3339) and the [`Principal`]
638/// that performed each action. Both actor fields are **non-optional** —
639/// system processes are still actors and must declare themselves via
640/// [`Principal::system`].
641///
642/// # Example
643///
644/// ```rust
645/// # #[cfg(feature = "chrono")] {
646/// use api_bones::{AuditInfo, Principal};
647///
648/// # #[cfg(feature = "uuid")] {
649/// use uuid::Uuid;
650/// let info = AuditInfo::now(Principal::human(Uuid::nil()));
651/// # }
652/// # }
653/// ```
654#[derive(Debug, Clone, PartialEq, Eq)]
655#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
656#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
657#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
658// When chrono is disabled, Timestamp = String which implements Arbitrary/proptest.
659#[cfg_attr(
660    all(feature = "arbitrary", not(feature = "chrono")),
661    derive(arbitrary::Arbitrary)
662)]
663#[cfg_attr(
664    all(feature = "proptest", not(feature = "chrono")),
665    derive(proptest_derive::Arbitrary)
666)]
667pub struct AuditInfo {
668    /// When the resource was created (RFC 3339).
669    #[cfg_attr(
670        feature = "utoipa",
671        schema(value_type = String, format = DateTime)
672    )]
673    pub created_at: Timestamp,
674    /// When the resource was last updated (RFC 3339).
675    #[cfg_attr(
676        feature = "utoipa",
677        schema(value_type = String, format = DateTime)
678    )]
679    pub updated_at: Timestamp,
680    /// Identity of the actor who created the resource.
681    pub created_by: Principal,
682    /// Identity of the actor who last updated the resource.
683    pub updated_by: Principal,
684}
685
686impl AuditInfo {
687    /// Construct an `AuditInfo` with explicit timestamps and principals.
688    ///
689    /// # Examples
690    ///
691    /// ```rust
692    /// # #[cfg(all(feature = "chrono", feature = "uuid"))] {
693    /// use api_bones::{AuditInfo, Principal};
694    /// use chrono::Utc;
695    /// use uuid::Uuid;
696    ///
697    /// let now = Utc::now();
698    /// let actor = Principal::human(Uuid::nil());
699    /// let info = AuditInfo::new(now, now, actor.clone(), actor);
700    /// # }
701    /// ```
702    #[must_use]
703    pub fn new(
704        created_at: Timestamp,
705        updated_at: Timestamp,
706        created_by: Principal,
707        updated_by: Principal,
708    ) -> Self {
709        Self {
710            created_at,
711            updated_at,
712            created_by,
713            updated_by,
714        }
715    }
716
717    /// Construct an `AuditInfo` with `created_at` and `updated_at` set to
718    /// the current UTC time. `updated_by` is initialized to a clone of
719    /// `created_by`.
720    ///
721    /// Requires the `chrono` feature.
722    ///
723    /// # Examples
724    ///
725    /// ```rust
726    /// # #[cfg(feature = "chrono")] {
727    /// use api_bones::{AuditInfo, Principal};
728    ///
729    /// # use uuid::Uuid;
730    /// let actor = Principal::human(Uuid::nil());
731    /// let info = AuditInfo::now(actor.clone());
732    /// assert_eq!(info.created_by, actor);
733    /// assert_eq!(info.updated_by, actor);
734    /// # }
735    /// ```
736    #[cfg(feature = "chrono")]
737    #[must_use]
738    pub fn now(created_by: Principal) -> Self {
739        let now = chrono::Utc::now();
740        let updated_by = created_by.clone();
741        Self {
742            created_at: now,
743            updated_at: now,
744            created_by,
745            updated_by,
746        }
747    }
748
749    /// Update `updated_at` to the current UTC time and set `updated_by`.
750    ///
751    /// Requires the `chrono` feature.
752    ///
753    /// # Examples
754    ///
755    /// ```rust
756    /// # #[cfg(feature = "chrono")] {
757    /// use api_bones::{AuditInfo, Principal};
758    ///
759    /// # use uuid::Uuid;
760    /// let mut info = AuditInfo::now(Principal::human(Uuid::nil()));
761    /// info.touch(Principal::system("billing.rotation-engine"));
762    /// assert_eq!(info.updated_by.as_str(), "billing.rotation-engine");
763    /// # }
764    /// ```
765    #[cfg(feature = "chrono")]
766    pub fn touch(&mut self, updated_by: Principal) {
767        self.updated_at = chrono::Utc::now();
768        self.updated_by = updated_by;
769    }
770}
771
772// ---------------------------------------------------------------------------
773// ResolvedPrincipal — read-path display helper
774// ---------------------------------------------------------------------------
775
776/// A [`Principal`] paired with an optional human-readable display name.
777///
778/// `Principal` stores only an opaque UUID — never PII. When a presentation
779/// layer (API response, audit log UI) needs to show a user-friendly name, an
780/// identity service resolves the UUID at read time and wraps it here.
781/// The display name is **never persisted**; only the opaque `id` is stored.
782///
783/// # Examples
784///
785/// ```rust
786/// use api_bones::{Principal, ResolvedPrincipal};
787/// # #[cfg(feature = "uuid")] {
788/// use uuid::Uuid;
789///
790/// let id = Principal::human(Uuid::nil());
791/// let r = ResolvedPrincipal::new(id, Some("Alice Martin".to_owned()));
792/// assert_eq!(r.display(), "Alice Martin");
793///
794/// let anonymous = ResolvedPrincipal::new(Principal::human(Uuid::nil()), None);
795/// assert_eq!(anonymous.display(), anonymous.id.as_str());
796/// # }
797/// ```
798#[derive(Debug, Clone, PartialEq, Eq)]
799#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
800pub struct ResolvedPrincipal {
801    /// The opaque, stored identity.
802    pub id: Principal,
803    /// Human-readable display name resolved from the identity service.
804    /// `None` when the resolution has not been performed or the actor is
805    /// a system principal with no display name.
806    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
807    pub display_name: Option<String>,
808}
809
810impl ResolvedPrincipal {
811    /// Wrap a [`Principal`] with an optional display name.
812    #[must_use]
813    pub fn new(id: Principal, display_name: Option<String>) -> Self {
814        Self { id, display_name }
815    }
816
817    /// Return the display name when available, otherwise fall back to the
818    /// opaque principal string (UUID or system name).
819    #[must_use]
820    pub fn display(&self) -> &str {
821        self.display_name
822            .as_deref()
823            .unwrap_or_else(|| self.id.as_str())
824    }
825}
826
827impl From<Principal> for ResolvedPrincipal {
828    fn from(id: Principal) -> Self {
829        Self {
830            id,
831            display_name: None,
832        }
833    }
834}
835
836// ---------------------------------------------------------------------------
837// arbitrary / proptest impls — chrono Timestamp requires manual impl
838// ---------------------------------------------------------------------------
839
840/// When `chrono` is enabled, `Timestamp = chrono::DateTime<Utc>` which does
841/// not implement `arbitrary::Arbitrary`, so we provide a hand-rolled impl.
842#[cfg(all(feature = "arbitrary", feature = "chrono"))]
843impl<'a> arbitrary::Arbitrary<'a> for AuditInfo {
844    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
845        // Generate timestamps as i64 seconds in a sane range (year 2000–3000).
846        let created_secs = <i64 as arbitrary::Arbitrary>::arbitrary(u)? % 32_503_680_000i64;
847        let updated_secs = <i64 as arbitrary::Arbitrary>::arbitrary(u)? % 32_503_680_000i64;
848        let created_at = chrono::DateTime::from_timestamp(created_secs.abs(), 0)
849            .unwrap_or_else(chrono::Utc::now);
850        let updated_at = chrono::DateTime::from_timestamp(updated_secs.abs(), 0)
851            .unwrap_or_else(chrono::Utc::now);
852        Ok(Self {
853            created_at,
854            updated_at,
855            created_by: Principal::arbitrary(u)?,
856            updated_by: Principal::arbitrary(u)?,
857        })
858    }
859}
860
861#[cfg(all(feature = "proptest", feature = "chrono"))]
862impl proptest::arbitrary::Arbitrary for AuditInfo {
863    type Parameters = ();
864    type Strategy = proptest::strategy::BoxedStrategy<Self>;
865
866    fn arbitrary_with((): ()) -> Self::Strategy {
867        use proptest::prelude::*;
868        (
869            0i64..=32_503_680_000i64,
870            0i64..=32_503_680_000i64,
871            any::<Principal>(),
872            any::<Principal>(),
873        )
874            .prop_map(|(cs, us, cb, ub)| Self {
875                created_at: chrono::DateTime::from_timestamp(cs, 0)
876                    .unwrap_or_else(chrono::Utc::now),
877                updated_at: chrono::DateTime::from_timestamp(us, 0)
878                    .unwrap_or_else(chrono::Utc::now),
879                created_by: cb,
880                updated_by: ub,
881            })
882            .boxed()
883    }
884}
885
886// ---------------------------------------------------------------------------
887// Tests
888// ---------------------------------------------------------------------------
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893    #[cfg(feature = "uuid")]
894    use uuid::Uuid;
895
896    // -- PrincipalId --------------------------------------------------------
897
898    #[test]
899    fn principal_id_static_str() {
900        let id = PrincipalId::static_str("foo");
901        assert_eq!(id.as_str(), "foo");
902    }
903
904    #[test]
905    fn principal_id_from_owned() {
906        let id = PrincipalId::from_owned("bar".to_owned());
907        assert_eq!(id.as_str(), "bar");
908    }
909
910    #[cfg(feature = "uuid")]
911    #[test]
912    fn principal_id_from_uuid() {
913        let id = PrincipalId::from_uuid(Uuid::nil());
914        assert_eq!(id.as_str(), "00000000-0000-0000-0000-000000000000");
915    }
916
917    #[test]
918    fn principal_id_display() {
919        let id = PrincipalId::static_str("test");
920        assert_eq!(format!("{id}"), "test");
921    }
922
923    #[cfg(feature = "serde")]
924    #[test]
925    fn principal_id_serde_transparent() {
926        let id = PrincipalId::static_str("myid");
927        let json = serde_json::to_value(&id).unwrap();
928        assert_eq!(json, serde_json::json!("myid"));
929        let back: PrincipalId = serde_json::from_value(json).unwrap();
930        assert_eq!(back, id);
931    }
932
933    // -- PrincipalKind -------------------------------------------------------
934
935    #[test]
936    fn principal_kind_copy_and_eq() {
937        let k1 = PrincipalKind::User;
938        let k2 = k1;
939        assert_eq!(k1, k2);
940    }
941
942    #[test]
943    fn principal_kind_all_variants() {
944        let _ = PrincipalKind::User;
945        let _ = PrincipalKind::Service;
946        let _ = PrincipalKind::System;
947        let _ = PrincipalKind::Device;
948        let _ = PrincipalKind::Agent;
949    }
950
951    // -- Principal --------------------------------------------------------
952
953    #[cfg(feature = "uuid")]
954    #[test]
955    fn principal_human_has_user_kind() {
956        let p = Principal::human(Uuid::nil());
957        assert_eq!(p.kind, PrincipalKind::User);
958    }
959
960    #[cfg(feature = "uuid")]
961    #[test]
962    fn principal_human_has_empty_org_path() {
963        let p = Principal::human(Uuid::nil());
964        assert!(p.org_path.is_empty());
965    }
966
967    #[test]
968    fn principal_system_has_system_kind() {
969        let p = Principal::system("s");
970        assert_eq!(p.kind, PrincipalKind::System);
971    }
972
973    #[cfg(feature = "uuid")]
974    #[test]
975    fn principal_system_has_empty_org_path() {
976        let p = Principal::system("s");
977        assert!(p.org_path.is_empty());
978    }
979
980    #[cfg(feature = "uuid")]
981    #[test]
982    fn principal_device_has_device_kind() {
983        let p = Principal::device(Uuid::nil());
984        assert_eq!(p.kind, PrincipalKind::Device);
985    }
986
987    #[cfg(feature = "uuid")]
988    #[test]
989    fn principal_device_id_is_uuid_string() {
990        let id = Uuid::new_v4();
991        let p = Principal::device(id);
992        assert_eq!(p.as_str(), id.to_string());
993    }
994
995    #[cfg(feature = "uuid")]
996    #[test]
997    fn principal_device_has_empty_org_path() {
998        let p = Principal::device(Uuid::nil());
999        assert!(p.org_path.is_empty());
1000    }
1001
1002    #[test]
1003    fn principal_agent_has_agent_kind() {
1004        let p = Principal::agent("ops.triage-agent");
1005        assert_eq!(p.kind, PrincipalKind::Agent);
1006    }
1007
1008    #[test]
1009    fn principal_agent_preserves_static_id() {
1010        let p = Principal::agent("sdr.outreach-bot");
1011        assert_eq!(p.as_str(), "sdr.outreach-bot");
1012    }
1013
1014    #[cfg(feature = "uuid")]
1015    #[test]
1016    fn principal_agent_has_empty_org_path() {
1017        let p = Principal::agent("svc");
1018        assert!(p.org_path.is_empty());
1019    }
1020
1021    #[cfg(feature = "uuid")]
1022    #[test]
1023    fn principal_with_org_path_builder() {
1024        let org_id = crate::org_id::OrgId::generate();
1025        let p = Principal::system("test").with_org_path(vec![org_id]);
1026        assert_eq!(p.org_path.len(), 1);
1027        assert_eq!(p.org_path[0], org_id);
1028    }
1029
1030    #[cfg(feature = "uuid")]
1031    #[test]
1032    fn org_path_display_empty_for_system_principal() {
1033        let p = Principal::system("svc");
1034        assert_eq!(p.org_path_display(), "");
1035    }
1036
1037    #[cfg(feature = "uuid")]
1038    #[test]
1039    fn org_path_display_single_org() {
1040        let org_id = crate::org_id::OrgId::generate();
1041        let p = Principal::system("svc").with_org_path(vec![org_id]);
1042        assert_eq!(p.org_path_display(), org_id.to_string());
1043    }
1044
1045    #[cfg(feature = "uuid")]
1046    #[test]
1047    fn org_path_display_multiple_orgs_comma_separated() {
1048        let root = crate::org_id::OrgId::generate();
1049        let child = crate::org_id::OrgId::generate();
1050        let p = Principal::system("svc").with_org_path(vec![root, child]);
1051        assert_eq!(p.org_path_display(), format!("{root},{child}"));
1052    }
1053
1054    #[cfg(feature = "uuid")]
1055    #[test]
1056    fn principal_try_parse_accepts_valid_uuid() {
1057        let s = "550e8400-e29b-41d4-a716-446655440000";
1058        let p = Principal::try_parse(s).expect("valid UUID should parse");
1059        assert_eq!(p.as_str(), s);
1060    }
1061
1062    #[cfg(feature = "uuid")]
1063    #[test]
1064    fn principal_try_parse_sets_user_kind() {
1065        let p = Principal::try_parse("550e8400-e29b-41d4-a716-446655440000").unwrap();
1066        assert_eq!(p.kind, PrincipalKind::User);
1067    }
1068
1069    #[cfg(feature = "uuid")]
1070    #[test]
1071    fn principal_try_parse_rejects_email_string() {
1072        let err = Principal::try_parse("alice@example.com").expect_err("email must be rejected");
1073        assert_eq!(err.input, "alice@example.com");
1074        assert!(err.to_string().contains("alice@example.com"));
1075    }
1076
1077    #[cfg(feature = "uuid")]
1078    #[test]
1079    fn principal_try_parse_rejects_empty_string() {
1080        let err = Principal::try_parse("").expect_err("empty string must be rejected");
1081        assert_eq!(err.input, "");
1082    }
1083
1084    #[test]
1085    fn principal_as_str_returns_id_str() {
1086        let p = Principal::system("x");
1087        assert_eq!(p.as_str(), "x");
1088    }
1089
1090    #[cfg(feature = "uuid")]
1091    #[test]
1092    fn principal_display_forwards_to_as_str() {
1093        let p = Principal::human(Uuid::nil());
1094        let s = format!("{p}");
1095        assert_eq!(s, Uuid::nil().to_string());
1096    }
1097
1098    #[cfg(feature = "uuid")]
1099    #[test]
1100    fn principal_debug_is_not_redacted() {
1101        let p = Principal::human(Uuid::nil());
1102        let s = format!("{p:?}");
1103        assert!(
1104            s.contains(&Uuid::nil().to_string()),
1105            "debug must not redact: {s}"
1106        );
1107        assert!(s.contains("Principal"), "debug must name the type: {s}");
1108    }
1109
1110    #[test]
1111    fn principal_equality_and_hash_across_owned_and_borrowed() {
1112        use std::collections::hash_map::DefaultHasher;
1113        use std::hash::{Hash, Hasher};
1114
1115        let p1 = Principal::system("orders.bootstrap");
1116        let p2 = Principal::system("orders.bootstrap");
1117        assert_eq!(p1, p2);
1118
1119        let mut h1 = DefaultHasher::new();
1120        p1.hash(&mut h1);
1121        let mut h2 = DefaultHasher::new();
1122        p2.hash(&mut h2);
1123        assert_eq!(h1.finish(), h2.finish());
1124    }
1125
1126    #[cfg(feature = "uuid")]
1127    #[test]
1128    fn principal_clone_roundtrip() {
1129        let p = Principal::human(Uuid::nil());
1130        let q = p.clone();
1131        assert_eq!(p, q);
1132    }
1133
1134    #[cfg(all(feature = "serde", feature = "uuid"))]
1135    #[test]
1136    fn principal_serde_struct_roundtrip_human() {
1137        let p = Principal::human(Uuid::nil());
1138        let json = serde_json::to_value(&p).unwrap();
1139        let back: Principal = serde_json::from_value(json).unwrap();
1140        assert_eq!(back, p);
1141    }
1142
1143    #[cfg(feature = "serde")]
1144    #[test]
1145    fn principal_serde_struct_roundtrip_system() {
1146        let p = Principal::system("billing.rotation-engine");
1147        let json = serde_json::to_value(&p).unwrap();
1148        let back: Principal = serde_json::from_value(json).unwrap();
1149        assert_eq!(back, p);
1150    }
1151
1152    #[cfg(all(feature = "serde", feature = "uuid"))]
1153    #[test]
1154    fn principal_serde_includes_org_path() {
1155        let p = Principal::system("test");
1156        let json = serde_json::to_value(&p).unwrap();
1157        assert!(json.get("org_path").is_some());
1158    }
1159
1160    // -- AuditInfo --------------------------------------------------------
1161
1162    #[cfg(all(feature = "chrono", feature = "uuid"))]
1163    #[test]
1164    fn now_sets_created_at_and_updated_at() {
1165        let actor = Principal::human(Uuid::nil());
1166        let before = chrono::Utc::now();
1167        let info = AuditInfo::now(actor.clone());
1168        let after = chrono::Utc::now();
1169
1170        assert!(info.created_at >= before && info.created_at <= after);
1171        assert!(info.updated_at >= before && info.updated_at <= after);
1172        assert_eq!(info.created_by, actor);
1173        assert_eq!(info.updated_by, actor);
1174    }
1175
1176    #[cfg(all(feature = "chrono", feature = "serde"))]
1177    #[test]
1178    fn now_with_system_principal() {
1179        let info = AuditInfo::now(Principal::system("billing.rotation-engine"));
1180        let json = serde_json::to_value(&info).unwrap();
1181        let back: AuditInfo = serde_json::from_value(json).unwrap();
1182        assert_eq!(back, info);
1183    }
1184
1185    #[cfg(all(feature = "chrono", feature = "uuid"))]
1186    #[test]
1187    fn touch_updates_updated_at_and_updated_by() {
1188        let mut info = AuditInfo::now(Principal::human(Uuid::nil()));
1189        let engine = Principal::system("billing.rotation-engine");
1190        let before_touch = chrono::Utc::now();
1191        info.touch(engine.clone());
1192        let after_touch = chrono::Utc::now();
1193
1194        assert!(info.updated_at >= before_touch && info.updated_at <= after_touch);
1195        assert_eq!(info.updated_by, engine);
1196    }
1197
1198    #[cfg(all(feature = "chrono", feature = "uuid"))]
1199    #[test]
1200    fn new_constructor() {
1201        let now = chrono::Utc::now();
1202        let actor = Principal::human(Uuid::nil());
1203        let engine = Principal::system("billing.rotation-engine");
1204        let info = AuditInfo::new(now, now, actor.clone(), engine.clone());
1205        assert_eq!(info.created_at, now);
1206        assert_eq!(info.updated_at, now);
1207        assert_eq!(info.created_by, actor);
1208        assert_eq!(info.updated_by, engine);
1209    }
1210
1211    #[cfg(all(feature = "chrono", feature = "serde"))]
1212    #[test]
1213    fn serde_round_trip_with_system_actor() {
1214        let info = AuditInfo::now(Principal::system("orders.bootstrap"));
1215        let json = serde_json::to_value(&info).unwrap();
1216        let back: AuditInfo = serde_json::from_value(json).unwrap();
1217        assert_eq!(back, info);
1218    }
1219
1220    #[cfg(all(feature = "chrono", feature = "serde"))]
1221    #[test]
1222    fn serde_actor_fields_are_always_present() {
1223        let info = AuditInfo::now(Principal::system("orders.bootstrap"));
1224        let json = serde_json::to_value(&info).unwrap();
1225        assert!(
1226            json.get("created_by").is_some(),
1227            "created_by must always serialize"
1228        );
1229        assert!(
1230            json.get("updated_by").is_some(),
1231            "updated_by must always serialize"
1232        );
1233        // Principal is now a struct with id, kind, org_path fields
1234        assert_eq!(
1235            json["created_by"]["id"],
1236            serde_json::json!("orders.bootstrap")
1237        );
1238    }
1239
1240    // -- PrincipalParseError ----------------------------------------------
1241
1242    #[test]
1243    fn principal_parse_error_display_contains_input() {
1244        let err = PrincipalParseError {
1245            input: "bad-value".to_owned(),
1246        };
1247        assert!(err.to_string().contains("bad-value"));
1248    }
1249
1250    #[cfg(feature = "std")]
1251    #[test]
1252    fn principal_parse_error_is_std_error() {
1253        let err = PrincipalParseError {
1254            input: "x".to_owned(),
1255        };
1256        let _: &dyn std::error::Error = &err;
1257    }
1258
1259    // -- ResolvedPrincipal ------------------------------------------------
1260
1261    #[cfg(feature = "uuid")]
1262    #[test]
1263    fn resolved_principal_new_and_display_with_name() {
1264        let p = Principal::human(Uuid::nil());
1265        let r = ResolvedPrincipal::new(p, Some("Alice Martin".to_owned()));
1266        assert_eq!(r.display(), "Alice Martin");
1267    }
1268
1269    #[cfg(feature = "uuid")]
1270    #[test]
1271    fn resolved_principal_display_falls_back_to_uuid() {
1272        let p = Principal::human(Uuid::nil());
1273        let r = ResolvedPrincipal::new(p.clone(), None);
1274        assert_eq!(r.display(), p.as_str());
1275    }
1276
1277    #[cfg(feature = "uuid")]
1278    #[test]
1279    fn resolved_principal_from_principal() {
1280        let p = Principal::human(Uuid::nil());
1281        let r = ResolvedPrincipal::from(p.clone());
1282        assert_eq!(r.id, p);
1283        assert!(r.display_name.is_none());
1284    }
1285
1286    #[cfg(all(feature = "uuid", feature = "serde"))]
1287    #[test]
1288    fn resolved_principal_serde_omits_none_display_name() {
1289        let p = Principal::human(Uuid::nil());
1290        let r = ResolvedPrincipal::from(p);
1291        let json = serde_json::to_value(&r).unwrap();
1292        assert!(
1293            json.get("display_name").is_none(),
1294            "display_name must be absent when None"
1295        );
1296    }
1297
1298    #[cfg(all(feature = "uuid", feature = "serde"))]
1299    #[test]
1300    fn resolved_principal_serde_includes_display_name_when_set() {
1301        let p = Principal::human(Uuid::nil());
1302        let r = ResolvedPrincipal::new(p, Some("Bob".to_owned()));
1303        let json = serde_json::to_value(&r).unwrap();
1304        assert_eq!(json["display_name"], serde_json::json!("Bob"));
1305    }
1306
1307    // -- DeviceLeaseKind -------------------------------------------------------
1308
1309    #[test]
1310    fn device_lease_kind_variants_distinct() {
1311        assert_ne!(DeviceLeaseKind::Connection, DeviceLeaseKind::RequestStream);
1312    }
1313
1314    #[test]
1315    fn device_lease_kind_copy() {
1316        let k = DeviceLeaseKind::Connection;
1317        let k2 = k;
1318        assert_eq!(k, k2);
1319    }
1320
1321    // -- DeviceLease -----------------------------------------------------------
1322
1323    #[test]
1324    fn device_lease_new_stores_fields() {
1325        let lease = DeviceLease::new(DeviceLeaseKind::RequestStream, Some(100), 1800);
1326        assert_eq!(lease.kind, DeviceLeaseKind::RequestStream);
1327        assert_eq!(lease.max_concurrent, Some(100));
1328        assert_eq!(lease.refresh_secs, 1800);
1329    }
1330
1331    #[test]
1332    fn device_lease_new_clamps_refresh_to_max() {
1333        let lease = DeviceLease::new(DeviceLeaseKind::Connection, None, 9999);
1334        assert_eq!(lease.refresh_secs, DeviceLease::MAX_REFRESH_SECS);
1335    }
1336
1337    #[test]
1338    fn device_lease_max_refresh_secs_is_one_hour() {
1339        assert_eq!(DeviceLease::MAX_REFRESH_SECS, 3_600);
1340    }
1341
1342    #[test]
1343    fn device_lease_new_no_cap() {
1344        let lease = DeviceLease::new(DeviceLeaseKind::Connection, None, 600);
1345        assert_eq!(lease.max_concurrent, None);
1346        assert_eq!(lease.refresh_secs, 600);
1347    }
1348
1349    #[cfg(feature = "serde")]
1350    #[test]
1351    fn device_lease_kind_serde_roundtrip() {
1352        let k = DeviceLeaseKind::Connection;
1353        let json = serde_json::to_value(k).unwrap();
1354        let back: DeviceLeaseKind = serde_json::from_value(json).unwrap();
1355        assert_eq!(k, back);
1356    }
1357
1358    #[cfg(feature = "serde")]
1359    #[test]
1360    fn device_lease_serde_roundtrip() {
1361        let lease = DeviceLease::new(DeviceLeaseKind::RequestStream, Some(50), 300);
1362        let json = serde_json::to_value(&lease).unwrap();
1363        let back: DeviceLease = serde_json::from_value(json).unwrap();
1364        assert_eq!(back.kind, lease.kind);
1365        assert_eq!(back.max_concurrent, lease.max_concurrent);
1366        assert_eq!(back.refresh_secs, lease.refresh_secs);
1367    }
1368}