Skip to main content

auths_verifier/
types.rs

1//! Verification types: reports, statuses, and device DIDs.
2
3use crate::witness::WitnessQuorum;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7// ============================================================================
8// Verification Report Types
9// ============================================================================
10
11/// Machine-readable verification result containing status, chain details, and warnings.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct VerificationReport {
14    /// The overall verification status
15    pub status: VerificationStatus,
16    /// Details of each link in the verification chain
17    pub chain: Vec<ChainLink>,
18    /// Non-fatal warnings encountered during verification
19    pub warnings: Vec<String>,
20    /// Optional witness quorum result (present when witness verification was performed)
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub witness_quorum: Option<WitnessQuorum>,
23    /// Whether the attestation is anchored in the issuer's KEL via an ixn seal.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub anchored: Option<auths_keri::AnchorStatus>,
26    /// Structured duplicity warning from the shared-KEL detector.
27    ///
28    /// Fail-open: `Valid` with a `Some(Diverging { … })` warning still
29    /// means the attestation signature verified. The warning surfaces
30    /// so CLI / iOS / CI can render a banner and point the user at
31    /// `auths device remove` to resolve.
32    ///
33    /// `None` indicates no divergence was observed (or no shared-KEL
34    /// replay was performed).
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub duplicity_warning: Option<crate::duplicity::DuplicityReport>,
37}
38
39impl VerificationReport {
40    /// Returns true only when the verification status is Valid.
41    pub fn is_valid(&self) -> bool {
42        matches!(self.status, VerificationStatus::Valid)
43    }
44
45    /// Creates a new valid VerificationReport with the given chain.
46    pub fn valid(chain: Vec<ChainLink>) -> Self {
47        Self {
48            status: VerificationStatus::Valid,
49            chain,
50            warnings: Vec::new(),
51            witness_quorum: None,
52            anchored: None,
53            duplicity_warning: None,
54        }
55    }
56
57    /// Creates a new VerificationReport with the given status and chain.
58    pub fn with_status(status: VerificationStatus, chain: Vec<ChainLink>) -> Self {
59        Self {
60            status,
61            chain,
62            warnings: Vec::new(),
63            witness_quorum: None,
64            anchored: None,
65            duplicity_warning: None,
66        }
67    }
68
69    /// Attach a duplicity warning to this report. Does not change `status` —
70    /// fail-open policy: a diverging shared KEL is an orthogonal signal to
71    /// per-attestation signature validity.
72    pub fn with_duplicity_warning(mut self, warning: crate::duplicity::DuplicityReport) -> Self {
73        self.duplicity_warning = Some(warning);
74        self
75    }
76}
77
78/// Verification outcome indicating success or the type of failure.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
80#[serde(tag = "type")]
81pub enum VerificationStatus {
82    /// The attestation(s) are valid
83    Valid,
84    /// The attestation has expired
85    Expired {
86        /// When the attestation expired
87        at: DateTime<Utc>,
88    },
89    /// The attestation has been revoked
90    Revoked {
91        /// When the attestation was revoked (if known)
92        at: Option<DateTime<Utc>>,
93    },
94    /// A signature in the chain is invalid
95    InvalidSignature {
96        /// The step in the chain where the invalid signature was found (0-indexed)
97        step: usize,
98    },
99    /// The chain has a broken link (issuer→subject mismatch or missing attestation)
100    BrokenChain {
101        /// Description of the missing link
102        missing_link: String,
103    },
104    /// Insufficient witness receipts to meet quorum threshold
105    InsufficientWitnesses {
106        /// Number of witnesses required
107        required: usize,
108        /// Number of witnesses that verified successfully
109        verified: usize,
110    },
111}
112
113/// A single link in a verification chain, representing one attestation's verification result.
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115pub struct ChainLink {
116    /// The issuer DID of this attestation
117    pub issuer: String,
118    /// The subject DID of this attestation
119    pub subject: String,
120    /// Whether this link's signature is valid
121    pub valid: bool,
122    /// Error message if verification failed
123    pub error: Option<String>,
124}
125
126impl ChainLink {
127    /// Creates a new valid chain link.
128    pub fn valid(issuer: String, subject: String) -> Self {
129        Self {
130            issuer,
131            subject,
132            valid: true,
133            error: None,
134        }
135    }
136
137    /// Creates a new invalid chain link with an error message.
138    pub fn invalid(issuer: String, subject: String, error: String) -> Self {
139        Self {
140            issuer,
141            subject,
142            valid: false,
143            error: Some(error),
144        }
145    }
146}
147
148// ============================================================================
149// DID Types
150// ============================================================================
151
152use std::borrow::Borrow;
153use std::fmt;
154use std::ops::Deref;
155use std::str::FromStr;
156
157// ============================================================================
158// IdentityDID Type
159// ============================================================================
160
161/// Strongly-typed wrapper for identity DIDs (e.g., `"did:keri:E..."`).
162///
163/// Usage:
164/// ```rust
165/// # use auths_verifier::IdentityDID;
166/// let did = IdentityDID::parse("did:keri:Eabc123").unwrap();
167/// assert_eq!(did.as_str(), "did:keri:Eabc123");
168///
169/// let s: String = did.into_inner();
170/// ```
171#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[repr(transparent)]
174pub struct IdentityDID(String);
175
176impl IdentityDID {
177    /// Wraps a DID string without validation (for trusted internal paths).
178    pub fn new_unchecked<S: Into<String>>(s: S) -> Self {
179        Self(s.into())
180    }
181
182    /// Validates and parses a `did:keri:` string into an `IdentityDID`.
183    ///
184    /// Args:
185    /// * `s`: A DID string that must start with `did:keri:` followed by a non-empty KERI prefix.
186    ///
187    /// Usage:
188    /// ```rust
189    /// # use auths_verifier::IdentityDID;
190    /// let did = IdentityDID::parse("did:keri:EPrefix123").unwrap();
191    /// assert_eq!(did.as_str(), "did:keri:EPrefix123");
192    /// ```
193    pub fn parse(s: &str) -> Result<Self, DidParseError> {
194        match s.strip_prefix("did:keri:") {
195            Some("") => Err(DidParseError::EmptyIdentifier),
196            Some(_) => Ok(Self(s.to_string())),
197            None => Err(DidParseError::InvalidIdentityPrefix(s.to_string())),
198        }
199    }
200
201    /// Builds an `IdentityDID` from a raw KERI prefix string.
202    ///
203    /// Args:
204    /// * `prefix`: The KERI prefix without the `did:keri:` scheme (e.g., `"EOrg123"`).
205    ///
206    /// Usage:
207    /// ```rust
208    /// # use auths_verifier::IdentityDID;
209    /// let did = IdentityDID::from_prefix("EOrg123").unwrap();
210    /// assert_eq!(did.as_str(), "did:keri:EOrg123");
211    /// ```
212    pub fn from_prefix(prefix: &str) -> Result<Self, DidParseError> {
213        if prefix.is_empty() {
214            return Err(DidParseError::EmptyIdentifier);
215        }
216        Ok(Self(format!("did:keri:{}", prefix)))
217    }
218
219    /// Returns the KERI prefix portion of the DID (after `did:keri:`).
220    ///
221    /// Usage:
222    /// ```rust
223    /// # use auths_verifier::IdentityDID;
224    /// let did = IdentityDID::parse("did:keri:EOrg123").unwrap();
225    /// assert_eq!(did.prefix(), "EOrg123");
226    /// ```
227    pub fn prefix(&self) -> &str {
228        self.0.strip_prefix("did:keri:").unwrap_or(&self.0)
229    }
230
231    /// Returns the DID as a string slice.
232    pub fn as_str(&self) -> &str {
233        &self.0
234    }
235
236    /// Consumes self and returns the inner String.
237    pub fn into_inner(self) -> String {
238        self.0
239    }
240}
241
242impl fmt::Display for IdentityDID {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        f.write_str(&self.0)
245    }
246}
247
248impl FromStr for IdentityDID {
249    type Err = DidParseError;
250
251    fn from_str(s: &str) -> Result<Self, Self::Err> {
252        Self::parse(s)
253    }
254}
255
256impl TryFrom<&str> for IdentityDID {
257    type Error = DidParseError;
258
259    fn try_from(s: &str) -> Result<Self, Self::Error> {
260        Self::parse(s)
261    }
262}
263
264impl TryFrom<String> for IdentityDID {
265    type Error = DidParseError;
266
267    fn try_from(s: String) -> Result<Self, Self::Error> {
268        Self::parse(&s)
269    }
270}
271
272impl From<IdentityDID> for String {
273    fn from(did: IdentityDID) -> String {
274        did.0
275    }
276}
277
278impl<'de> serde::Deserialize<'de> for IdentityDID {
279    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
280    where
281        D: serde::Deserializer<'de>,
282    {
283        let s = String::deserialize(deserializer)?;
284        Self::parse(&s).map_err(serde::de::Error::custom)
285    }
286}
287
288impl Deref for IdentityDID {
289    type Target = str;
290
291    fn deref(&self) -> &Self::Target {
292        &self.0
293    }
294}
295
296impl AsRef<str> for IdentityDID {
297    fn as_ref(&self) -> &str {
298        &self.0
299    }
300}
301
302impl Borrow<str> for IdentityDID {
303    fn borrow(&self) -> &str {
304        &self.0
305    }
306}
307
308impl PartialEq<str> for IdentityDID {
309    fn eq(&self, other: &str) -> bool {
310        self.0 == other
311    }
312}
313
314impl PartialEq<&str> for IdentityDID {
315    fn eq(&self, other: &&str) -> bool {
316        self.0 == *other
317    }
318}
319
320impl PartialEq<IdentityDID> for str {
321    fn eq(&self, other: &IdentityDID) -> bool {
322        self == other.0
323    }
324}
325
326impl PartialEq<IdentityDID> for &str {
327    fn eq(&self, other: &IdentityDID) -> bool {
328        *self == other.0
329    }
330}
331
332impl FromStr for CanonicalDid {
333    type Err = DidParseError;
334
335    fn from_str(s: &str) -> Result<Self, Self::Err> {
336        Self::parse(s)
337    }
338}
339
340// ============================================================================
341// DID Utility Functions
342// ============================================================================
343
344/// Convert a hex-encoded Ed25519 public key to a `did:key:` device DID.
345///
346/// The hex string must decode to exactly 32 bytes.
347///
348/// ```rust
349/// # use auths_verifier::types::signer_hex_to_did;
350/// let did = signer_hex_to_did("d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7ddc8").unwrap_err();
351/// // (example key is wrong length — a real 32-byte hex key would succeed)
352/// ```
353pub fn signer_hex_to_did(hex_key: &str) -> Result<CanonicalDid, DidConversionError> {
354    signer_hex_to_did_with_curve(hex_key, auths_crypto::CurveType::P256)
355}
356
357/// Convert a hex-encoded public key to a `did:key:` device DID with explicit curve.
358///
359/// ```rust
360/// # use auths_verifier::types::signer_hex_to_did_with_curve;
361/// # use auths_crypto::CurveType;
362/// let did = signer_hex_to_did_with_curve("d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7ddc8", CurveType::P256).unwrap_err();
363/// ```
364pub fn signer_hex_to_did_with_curve(
365    hex_key: &str,
366    curve: auths_crypto::CurveType,
367) -> Result<CanonicalDid, DidConversionError> {
368    let bytes = hex::decode(hex_key).map_err(|e| DidConversionError::InvalidHex(e.to_string()))?;
369    let expected = curve.public_key_len();
370    if bytes.len() != expected {
371        return Err(DidConversionError::WrongKeyLength(bytes.len()));
372    }
373    Ok(CanonicalDid::from_public_key_did_key(&bytes, curve))
374}
375
376/// Validate a DID string (accepts both `did:keri:` and `did:key:` formats).
377///
378/// Returns `true` if the DID has a recognized scheme and non-empty identifier.
379pub fn validate_did(did_str: &str) -> bool {
380    if let Some(rest) = did_str.strip_prefix("did:keri:") {
381        !rest.is_empty()
382    } else if let Some(rest) = did_str.strip_prefix("did:key:") {
383        !rest.is_empty()
384    } else {
385        false
386    }
387}
388
389/// Errors from DID conversion operations.
390#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
391pub enum DidConversionError {
392    /// The input is not valid hexadecimal.
393    #[error("invalid hex: {0}")]
394    InvalidHex(String),
395    /// The decoded key is not 32 bytes.
396    #[error("expected 32-byte Ed25519 key, got {0} bytes")]
397    WrongKeyLength(usize),
398}
399
400/// Errors from DID string parsing and validation.
401#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
402#[non_exhaustive]
403pub enum DidParseError {
404    /// A `did:key:z…`-shaped DID was required but the prefix didn't match.
405    #[error("did:key: DID must start with 'did:key:z', got: {0}")]
406    InvalidDevicePrefix(String),
407    /// IdentityDID must start with `did:keri:`.
408    #[error("IdentityDID must start with 'did:keri:', got: {0}")]
409    InvalidIdentityPrefix(String),
410    /// The method-specific identifier portion is empty.
411    #[error("DID method-specific identifier is empty")]
412    EmptyIdentifier,
413    /// Generic DID format validation failure (used by `CanonicalDid`).
414    #[error("{0}")]
415    InvalidFormat(String),
416    /// DID string contains control characters.
417    #[error("DID contains control characters")]
418    ControlCharacters,
419}
420
421// ============================================================================
422// CanonicalDid Type
423// ============================================================================
424
425/// A validated, canonical DID that accepts any method (`did:keri:`, `did:key:`, etc.).
426///
427/// Use this for fields that can hold either identity or device DIDs,
428/// such as attestation issuers which may be `did:keri:` or `did:key:`.
429///
430/// Constructed via `parse()` which enforces:
431/// - Starts with `did:`
432/// - Has at least method and id segments: `did:method:id`
433/// - Lowercased method (KERI, key methods are case-sensitive in id, not method)
434/// - No trailing whitespace or control characters
435#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
437#[serde(transparent)]
438pub struct CanonicalDid(String);
439
440impl CanonicalDid {
441    /// Parse and validate a DID string into canonical form.
442    pub fn parse(raw: &str) -> Result<Self, DidParseError> {
443        if raw.chars().any(|c| c.is_control()) {
444            return Err(DidParseError::ControlCharacters);
445        }
446        let trimmed = raw.trim();
447        if trimmed.is_empty() {
448            return Err(DidParseError::EmptyIdentifier);
449        }
450        let parts: Vec<&str> = trimmed.splitn(3, ':').collect();
451        if parts.len() < 3 || parts[0] != "did" || parts[1].is_empty() || parts[2].is_empty() {
452            return Err(DidParseError::InvalidFormat(format!(
453                "invalid DID format: '{}'",
454                trimmed
455            )));
456        }
457        let canonical = format!("did:{}:{}", parts[1].to_lowercase(), parts[2]);
458        Ok(Self(canonical))
459    }
460
461    /// Wraps a DID string without validation (for trusted internal paths).
462    pub fn new_unchecked<S: Into<String>>(s: S) -> Self {
463        Self(s.into())
464    }
465
466    /// Returns the canonical DID as a string slice.
467    pub fn as_str(&self) -> &str {
468        &self.0
469    }
470
471    /// Returns the method-specific identifier (the part after `did:method:`).
472    pub fn method_specific_id(&self) -> &str {
473        self.0.splitn(3, ':').nth(2).unwrap_or("")
474    }
475
476    /// Returns a sanitized version of the DID for use in Git refs,
477    /// replacing all non-alphanumeric characters with `_`.
478    pub fn ref_name(&self) -> String {
479        self.0
480            .chars()
481            .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
482            .collect()
483    }
484
485    /// Compares a sanitized DID ref name to this canonical DID.
486    pub fn matches_sanitized_ref(&self, ref_name: &str) -> bool {
487        self.ref_name() == ref_name
488    }
489
490    /// Tries to reverse-lookup a real DID from a sanitized string,
491    /// given a list of known real DIDs.
492    pub fn from_sanitized<'a>(sanitized: &str, known_dids: &'a [Self]) -> Option<&'a Self> {
493        known_dids.iter().find(|did| did.ref_name() == sanitized)
494    }
495
496    /// Construct a `did:key:z…` DID from a raw public key + curve tag.
497    ///
498    /// Constructs a `did:key:z…` DID from a raw public key + curve
499    /// using multicodec varint prefixes.
500    ///
501    /// Args:
502    /// * `pubkey`: 32 bytes (Ed25519) or 33 bytes (P-256 compressed SEC1).
503    /// * `curve`: The curve type carried in-band per the wire-format rules.
504    ///
505    /// Usage:
506    /// ```ignore
507    /// let did = CanonicalDid::from_public_key_did_key(&bytes, CurveType::P256);
508    /// ```
509    pub fn from_public_key_did_key(pubkey: &[u8], curve: auths_crypto::CurveType) -> Self {
510        let varint: &[u8] = match curve {
511            auths_crypto::CurveType::Ed25519 => &[0xED, 0x01],
512            auths_crypto::CurveType::P256 => &[0x80, 0x24],
513        };
514        let mut prefixed = Vec::with_capacity(varint.len() + pubkey.len());
515        prefixed.extend_from_slice(varint);
516        prefixed.extend_from_slice(pubkey);
517        let encoded = bs58::encode(prefixed).into_string();
518        Self(format!("did:key:z{}", encoded))
519    }
520
521    /// Validates that this DID uses the `keri` method with a valid KERI prefix.
522    pub fn require_keri(self) -> Result<Self, DidParseError> {
523        let parts: Vec<&str> = self.0.splitn(3, ':').collect();
524        if parts[1] != "keri" {
525            return Err(DidParseError::InvalidFormat(format!(
526                "expected did:keri: DID, got did:{}:",
527                parts[1]
528            )));
529        }
530        let id = parts[2];
531        if id.len() < 2 || id.len() > 128 {
532            return Err(DidParseError::InvalidFormat(
533                "invalid KERI prefix: length must be 2–128 characters".into(),
534            ));
535        }
536        if !id.starts_with(|c: char| c.is_ascii_uppercase()) {
537            return Err(DidParseError::InvalidFormat(format!(
538                "invalid KERI prefix: must start with an uppercase derivation code, got '{}'",
539                &id[..1]
540            )));
541        }
542        Ok(self)
543    }
544
545    /// Consumes self and returns the inner String.
546    pub fn into_inner(self) -> String {
547        self.0
548    }
549}
550
551impl TryFrom<String> for CanonicalDid {
552    type Error = DidParseError;
553    fn try_from(s: String) -> Result<Self, Self::Error> {
554        Self::parse(&s)
555    }
556}
557
558impl TryFrom<&str> for CanonicalDid {
559    type Error = DidParseError;
560    fn try_from(s: &str) -> Result<Self, Self::Error> {
561        Self::parse(s)
562    }
563}
564
565impl From<CanonicalDid> for String {
566    fn from(d: CanonicalDid) -> Self {
567        d.0
568    }
569}
570
571impl fmt::Display for CanonicalDid {
572    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
573        f.write_str(&self.0)
574    }
575}
576
577impl Deref for CanonicalDid {
578    type Target = str;
579    fn deref(&self) -> &str {
580        &self.0
581    }
582}
583
584impl AsRef<str> for CanonicalDid {
585    fn as_ref(&self) -> &str {
586        &self.0
587    }
588}
589
590impl Borrow<str> for CanonicalDid {
591    fn borrow(&self) -> &str {
592        &self.0
593    }
594}
595
596impl<'de> serde::Deserialize<'de> for CanonicalDid {
597    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
598    where
599        D: serde::Deserializer<'de>,
600    {
601        let s = String::deserialize(deserializer)?;
602        Self::parse(&s).map_err(serde::de::Error::custom)
603    }
604}
605
606impl PartialEq<str> for CanonicalDid {
607    fn eq(&self, other: &str) -> bool {
608        self.0 == other
609    }
610}
611
612impl PartialEq<&str> for CanonicalDid {
613    fn eq(&self, other: &&str) -> bool {
614        self.0 == *other
615    }
616}
617
618impl From<IdentityDID> for CanonicalDid {
619    fn from(did: IdentityDID) -> Self {
620        Self(did.into_inner())
621    }
622}
623
624// ============================================================================
625// AssuranceLevel Type
626// ============================================================================
627
628/// Cryptographic assurance level of a platform identity claim.
629///
630/// Variants are ordered from weakest to strongest so that `Ord` comparisons
631/// reflect trust strength: `SelfAsserted < TokenVerified < Authenticated < Sovereign`.
632///
633/// Usage:
634/// ```rust
635/// # use auths_verifier::types::AssuranceLevel;
636/// assert!(AssuranceLevel::Sovereign > AssuranceLevel::Authenticated);
637/// assert!(AssuranceLevel::SelfAsserted < AssuranceLevel::TokenVerified);
638/// ```
639#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
640#[serde(rename_all = "snake_case")]
641#[non_exhaustive]
642pub enum AssuranceLevel {
643    /// Self-reported identity, signed only by the claimant's own key (e.g., PyPI).
644    SelfAsserted,
645    /// Bearer token validated against a platform API at time of claim (e.g., npm).
646    TokenVerified,
647    /// OAuth/OIDC challenge-response proving account control (e.g., GitHub).
648    Authenticated,
649    /// End-to-end cryptographic identity chain with no third-party trust (auths native).
650    Sovereign,
651}
652
653/// Error returned when parsing an `AssuranceLevel` from a string fails.
654#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
655#[error(
656    "invalid assurance level '{0}': expected one of: sovereign, authenticated, token_verified, self_asserted"
657)]
658pub struct AssuranceLevelParseError(pub String);
659
660impl AssuranceLevel {
661    /// Human-readable label for display.
662    pub fn label(&self) -> &'static str {
663        match self {
664            Self::SelfAsserted => "Self-Asserted",
665            Self::TokenVerified => "Token-Verified",
666            Self::Authenticated => "Authenticated",
667            Self::Sovereign => "Sovereign",
668        }
669    }
670
671    /// Numeric score (1–4) for the assurance level.
672    pub fn score(&self) -> u8 {
673        match self {
674            Self::SelfAsserted => 1,
675            Self::TokenVerified => 2,
676            Self::Authenticated => 3,
677            Self::Sovereign => 4,
678        }
679    }
680}
681
682impl fmt::Display for AssuranceLevel {
683    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
684        f.write_str(self.label())
685    }
686}
687
688impl FromStr for AssuranceLevel {
689    type Err = AssuranceLevelParseError;
690
691    fn from_str(s: &str) -> Result<Self, Self::Err> {
692        match s.trim().to_lowercase().as_str() {
693            "sovereign" => Ok(Self::Sovereign),
694            "authenticated" => Ok(Self::Authenticated),
695            "token_verified" => Ok(Self::TokenVerified),
696            "self_asserted" => Ok(Self::SelfAsserted),
697            _ => Err(AssuranceLevelParseError(s.to_string())),
698        }
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705    use auths_keri::Said;
706
707    #[test]
708    fn report_without_witness_quorum_deserializes() {
709        // JSON from before witness_quorum field existed
710        let json = r#"{
711            "status": {"type": "Valid"},
712            "chain": [],
713            "warnings": []
714        }"#;
715        let report: VerificationReport = serde_json::from_str(json).unwrap();
716        assert!(report.is_valid());
717        assert!(report.witness_quorum.is_none());
718    }
719
720    #[test]
721    fn insufficient_witnesses_serializes_correctly() {
722        let status = VerificationStatus::InsufficientWitnesses {
723            required: 3,
724            verified: 1,
725        };
726        let json = serde_json::to_string(&status).unwrap();
727        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
728        assert_eq!(parsed["type"], "InsufficientWitnesses");
729        assert_eq!(parsed["required"], 3);
730        assert_eq!(parsed["verified"], 1);
731
732        // Roundtrip
733        let roundtripped: VerificationStatus = serde_json::from_str(&json).unwrap();
734        assert_eq!(roundtripped, status);
735    }
736
737    #[test]
738    fn report_with_witness_quorum_roundtrips() {
739        use crate::witness::{WitnessQuorum, WitnessReceiptResult};
740
741        let report = VerificationReport {
742            status: VerificationStatus::Valid,
743            chain: vec![],
744            warnings: vec![],
745            witness_quorum: Some(WitnessQuorum {
746                required: 2,
747                verified: 2,
748                receipts: vec![
749                    WitnessReceiptResult {
750                        witness_id: "did:key:w1".into(),
751                        receipt_said: Said::new_unchecked("EReceipt1".into()),
752                        verified: true,
753                    },
754                    WitnessReceiptResult {
755                        witness_id: "did:key:w2".into(),
756                        receipt_said: Said::new_unchecked("EReceipt2".into()),
757                        verified: true,
758                    },
759                ],
760            }),
761            anchored: None,
762            duplicity_warning: None,
763        };
764
765        let json = serde_json::to_string(&report).unwrap();
766        let parsed: VerificationReport = serde_json::from_str(&json).unwrap();
767        assert_eq!(report, parsed);
768        assert!(parsed.witness_quorum.is_some());
769        assert_eq!(parsed.witness_quorum.unwrap().verified, 2);
770    }
771
772    #[test]
773    fn report_without_witness_quorum_skips_in_json() {
774        let report = VerificationReport::valid(vec![]);
775        let json = serde_json::to_string(&report).unwrap();
776        // witness_quorum should be omitted from JSON when None
777        assert!(!json.contains("witness_quorum"));
778    }
779
780    // ── AssuranceLevel Tests ──────────────────────────────────────────
781
782    #[test]
783    fn assurance_level_ordering() {
784        assert!(AssuranceLevel::SelfAsserted < AssuranceLevel::TokenVerified);
785        assert!(AssuranceLevel::TokenVerified < AssuranceLevel::Authenticated);
786        assert!(AssuranceLevel::Authenticated < AssuranceLevel::Sovereign);
787    }
788
789    #[test]
790    fn assurance_level_serde_roundtrip() {
791        let variants = [
792            AssuranceLevel::SelfAsserted,
793            AssuranceLevel::TokenVerified,
794            AssuranceLevel::Authenticated,
795            AssuranceLevel::Sovereign,
796        ];
797        for level in variants {
798            let json = serde_json::to_string(&level).unwrap();
799            let parsed: AssuranceLevel = serde_json::from_str(&json).unwrap();
800            assert_eq!(parsed, level);
801        }
802    }
803
804    #[test]
805    fn assurance_level_serde_snake_case() {
806        assert_eq!(
807            serde_json::to_string(&AssuranceLevel::SelfAsserted).unwrap(),
808            "\"self_asserted\""
809        );
810        assert_eq!(
811            serde_json::to_string(&AssuranceLevel::TokenVerified).unwrap(),
812            "\"token_verified\""
813        );
814        assert_eq!(
815            serde_json::to_string(&AssuranceLevel::Authenticated).unwrap(),
816            "\"authenticated\""
817        );
818        assert_eq!(
819            serde_json::to_string(&AssuranceLevel::Sovereign).unwrap(),
820            "\"sovereign\""
821        );
822    }
823
824    #[test]
825    fn assurance_level_from_str() {
826        assert_eq!(
827            "sovereign".parse::<AssuranceLevel>().unwrap(),
828            AssuranceLevel::Sovereign
829        );
830        assert_eq!(
831            "authenticated".parse::<AssuranceLevel>().unwrap(),
832            AssuranceLevel::Authenticated
833        );
834        assert_eq!(
835            "token_verified".parse::<AssuranceLevel>().unwrap(),
836            AssuranceLevel::TokenVerified
837        );
838        assert_eq!(
839            "self_asserted".parse::<AssuranceLevel>().unwrap(),
840            AssuranceLevel::SelfAsserted
841        );
842        assert!("invalid".parse::<AssuranceLevel>().is_err());
843    }
844
845    #[test]
846    fn assurance_level_from_str_case_insensitive() {
847        assert_eq!(
848            "SOVEREIGN".parse::<AssuranceLevel>().unwrap(),
849            AssuranceLevel::Sovereign
850        );
851        assert_eq!(
852            "Authenticated".parse::<AssuranceLevel>().unwrap(),
853            AssuranceLevel::Authenticated
854        );
855    }
856
857    #[test]
858    fn assurance_level_score() {
859        assert_eq!(AssuranceLevel::SelfAsserted.score(), 1);
860        assert_eq!(AssuranceLevel::TokenVerified.score(), 2);
861        assert_eq!(AssuranceLevel::Authenticated.score(), 3);
862        assert_eq!(AssuranceLevel::Sovereign.score(), 4);
863    }
864
865    #[test]
866    fn assurance_level_display() {
867        assert_eq!(AssuranceLevel::SelfAsserted.to_string(), "Self-Asserted");
868        assert_eq!(AssuranceLevel::TokenVerified.to_string(), "Token-Verified");
869        assert_eq!(AssuranceLevel::Authenticated.to_string(), "Authenticated");
870        assert_eq!(AssuranceLevel::Sovereign.to_string(), "Sovereign");
871    }
872}