Skip to main content

didwebvh_rs/resolve/
mod.rs

1//! Resolving WebVH DID's logic is handled here
2//!
3//! A WebVH DID can be loaded via HTTP(S), local file (testing), or raw string data
4//! [`crate::DIDWebVHState::resolve`] Will load a WebVH DID using HTTP(S)
5//! [`crate::DIDWebVHState::resolve_file`] Will load a WebVH DID using a local file path
6//! [`crate::DIDWebVHState::resolve_log`] Will load a WebVH DID from raw JSONL string data
7//! `resolve_state` is an internal function that will validate the DID and return
8//! the resolved result
9
10#[cfg(feature = "network")]
11use crate::url::URLType;
12use crate::{
13    DIDWebVHError, DIDWebVHState,
14    log_entry::{LogEntry, LogEntryMethods, MetaData},
15    log_entry_state::{LogEntryState, LogEntryValidationStatus},
16    parameters::Parameters,
17    url::WebVHURL,
18    witness::proofs::WitnessProofCollection,
19};
20use chrono::DateTime;
21#[cfg(feature = "network")]
22use chrono::Utc;
23#[cfg(feature = "network")]
24use reqwest::{Client, StatusCode};
25#[cfg(feature = "network")]
26use std::time::Duration;
27#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
28use tracing::trace;
29#[cfg(feature = "network")]
30use tracing::warn;
31use tracing::{Instrument, Level, span};
32#[cfg(feature = "network")]
33use url::Url;
34
35/// Integration with the Spruice ID SSI Library
36#[cfg(feature = "ssi")]
37pub mod ssi_resolve;
38
39pub mod implicit; // WebVH specification implies specific Services for a DID Document
40
41/// Default maximum HTTP response size: 200 KB.
42#[cfg(feature = "network")]
43pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 200 * 1024;
44
45/// Options for network-based DID resolution.
46#[cfg(feature = "network")]
47#[derive(Debug, Clone)]
48pub struct ResolveOptions {
49    /// Network timeout (default: 10 seconds).
50    pub timeout: Option<Duration>,
51    /// Download witnesses concurrently with log entries (default: false).
52    pub eager_witness_download: bool,
53    /// Maximum allowed HTTP response body size in bytes (default: 200 KB).
54    /// Applies independently to each downloaded file (did.jsonl, did-witness.json).
55    pub max_response_bytes: u64,
56}
57
58#[cfg(feature = "network")]
59impl Default for ResolveOptions {
60    fn default() -> Self {
61        Self {
62            timeout: None,
63            eager_witness_download: false,
64            max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
65        }
66    }
67}
68
69/// HTTP client helpers for fetching DID log entries and witness proofs.
70#[cfg(feature = "network")]
71pub struct DIDWebVH;
72
73#[cfg(feature = "network")]
74impl DIDWebVH {
75    /// Fetches a file from the given URL, enforcing a maximum response body size.
76    ///
77    /// The size limit is checked in two ways:
78    /// 1. If the server provides a `Content-Length` header, the response is rejected
79    ///    immediately when the advertised size exceeds `max_bytes`.
80    /// 2. The body is read in chunks, and the cumulative size is checked against
81    ///    `max_bytes` as data arrives. This catches cases where `Content-Length` is
82    ///    absent or inaccurate (e.g. chunked transfer encoding).
83    async fn download_file(
84        client: Client,
85        url: Url,
86        max_bytes: u64,
87    ) -> Result<String, DIDWebVHError> {
88        let url_str = url.to_string();
89        let mut response =
90            client
91                .get(url.clone())
92                .send()
93                .await
94                .map_err(|e| DIDWebVHError::NetworkError {
95                    url: url_str.clone(),
96                    status_code: None,
97                    message: format!("Request failed: {e}"),
98                })?;
99
100        if response.status() != StatusCode::OK {
101            let status = response.status().as_u16();
102            warn!("url ({url_str}): HTTP Status code = {status}");
103            return Err(DIDWebVHError::NetworkError {
104                url: url_str,
105                status_code: Some(status),
106                message: format!("HTTP {status}"),
107            });
108        }
109
110        // Early rejection based on Content-Length header
111        if let Some(content_length) = response.content_length()
112            && content_length > max_bytes
113        {
114            return Err(DIDWebVHError::ResponseTooLarge {
115                url: url_str,
116                max_bytes,
117            });
118        }
119
120        // Read body in chunks, enforcing the size limit as data arrives
121        let mut body = Vec::new();
122        let mut total_bytes: u64 = 0;
123        while let Some(chunk) = response
124            .chunk()
125            .await
126            .map_err(|e| DIDWebVHError::NetworkError {
127                url: url_str.clone(),
128                status_code: Some(200),
129                message: format!("Failed to read response body: {e}"),
130            })?
131        {
132            total_bytes += chunk.len() as u64;
133            if total_bytes > max_bytes {
134                return Err(DIDWebVHError::ResponseTooLarge {
135                    url: url_str,
136                    max_bytes,
137                });
138            }
139            body.extend_from_slice(&chunk);
140        }
141
142        String::from_utf8(body).map_err(|e| DIDWebVHError::NetworkError {
143            url: url_str,
144            status_code: Some(200),
145            message: format!("Response body is not valid UTF-8: {e}"),
146        })
147    }
148
149    /// Handles all processing and fetching for LogEntry file
150    async fn get_log_entries(
151        url: WebVHURL,
152        client: Client,
153        max_bytes: u64,
154    ) -> Result<String, DIDWebVHError> {
155        let log_entries_url = match url.get_http_url(Some("did.jsonl")) {
156            Ok(url) => url,
157            Err(e) => {
158                warn!("Invalid URL for DID: {e}");
159                return Err(DIDWebVHError::InvalidMethodIdentifier(format!(
160                    "Couldn't generate a valid URL from the DID: {e}"
161                )));
162            }
163        };
164
165        Self::download_file(client, log_entries_url, max_bytes).await
166    }
167
168    /// Handles all processing and fetching for witness proofs
169    async fn get_witness_proofs(
170        url: WebVHURL,
171        client: Client,
172        max_bytes: u64,
173    ) -> Result<String, DIDWebVHError> {
174        let witness_url = match url.get_http_url(Some("did-witness.json")) {
175            Ok(url) => url,
176            Err(e) => {
177                warn!("Invalid URL for DID: {e}");
178                return Err(DIDWebVHError::InvalidMethodIdentifier(format!(
179                    "Couldn't generate a valid URL from the DID: {e}"
180                )));
181            }
182        };
183
184        Self::download_file(client, witness_url, max_bytes).await
185    }
186}
187
188impl DIDWebVHState {
189    /// Load a WebVH DID from a local file (useful for testing)
190    /// did: DID to resolve (can use query parameters here)
191    /// log_entries_path: path to the did.jsonl file
192    /// witness_proofs_file: optional path to the did-witness.json file
193    pub async fn resolve_file(
194        &mut self,
195        did: &str,
196        log_entries_path: &str,
197        witness_proofs_file: Option<&str>,
198    ) -> Result<(&LogEntry, MetaData), DIDWebVHError> {
199        let _span = span!(Level::DEBUG, "resolve_file", PATH = log_entries_path);
200        async move {
201            let parsed_did_url = WebVHURL::parse_did_url(did)?;
202
203            // Load log entries from file
204            self.load_log_entries_from_file(log_entries_path)?;
205
206            // Load witness proofs from file if provided
207            if let Some(witness_path) = witness_proofs_file {
208                self.load_witness_proofs_from_file(witness_path);
209            } else {
210                self.witness_proofs = WitnessProofCollection::default();
211            }
212
213            // Have LogEntries and Witness Proofs, now can validate the DID
214            self.validated = false;
215            self.expires = DateTime::default();
216
217            self.resolve_state(&parsed_did_url)
218        }
219        .instrument(_span)
220        .await
221    }
222
223    /// Like [`resolve_file()`](Self::resolve_file), but returns owned (cloned) values
224    /// so the caller does not borrow `self`.
225    pub async fn resolve_file_owned(
226        &mut self,
227        did: &str,
228        log_entries_path: &str,
229        witness_proofs_file: Option<&str>,
230    ) -> Result<(LogEntry, MetaData), DIDWebVHError> {
231        let (entry, metadata) = self
232            .resolve_file(did, log_entries_path, witness_proofs_file)
233            .await?;
234        Ok((entry.clone(), metadata))
235    }
236
237    /// Parse raw JSONL log entry lines into a vec of [`LogEntryState`].
238    ///
239    /// Each line in `raw` must be a valid JSON-serialized log entry.
240    /// Returns an error if any line fails to parse.
241    pub fn parse_log_entries(raw: &str) -> Result<Vec<LogEntryState>, DIDWebVHError> {
242        let mut log_entries = Vec::new();
243        let mut version = None;
244        for line in raw.lines() {
245            let log_entry = LogEntry::deserialize_string(line, version)?;
246            version = Some(log_entry.get_webvh_version());
247            log_entries.push(LogEntryState {
248                log_entry: log_entry.clone(),
249                version_number: log_entry.get_version_id_fields()?.0,
250                validation_status: LogEntryValidationStatus::NotValidated,
251                validated_parameters: Parameters::default(),
252            });
253        }
254        Ok(log_entries)
255    }
256
257    /// Check whether any log entry has a non-empty witness parameter.
258    pub fn needs_witness_proofs(log_entries: &[LogEntryState]) -> bool {
259        log_entries.iter().any(|e| {
260            e.log_entry
261                .get_parameters()
262                .witness
263                .as_ref()
264                .is_some_and(|w| !w.is_empty())
265        })
266    }
267
268    /// Parse a raw witness proofs JSON string into a [`WitnessProofCollection`].
269    pub fn parse_witness_proofs(raw: &str) -> Result<WitnessProofCollection, DIDWebVHError> {
270        Ok(WitnessProofCollection {
271            proofs: serde_json::from_str(raw).map_err(|e| {
272                DIDWebVHError::WitnessProofError(format!(
273                    "Couldn't deserialize Witness Proofs Data: {e}",
274                ))
275            })?,
276            ..Default::default()
277        })
278    }
279
280    /// Validate that parsed log entries are non-empty, returning a contextual error.
281    fn validate_log_entries(log_entries: &[LogEntryState], did: &str) -> Result<(), DIDWebVHError> {
282        if log_entries.is_empty() {
283            return Err(DIDWebVHError::NotFound(format!(
284                "No LogEntries found for DID: {did}",
285            )));
286        }
287        Ok(())
288    }
289
290    /// Resolve a `did:webvh` DID from raw JSONL log data and optional witness proofs.
291    ///
292    /// This method performs the same cryptographic verification as [`resolve()`](Self::resolve)
293    /// and [`resolve_file()`](Self::resolve_file), but accepts the log data as in-memory strings
294    /// rather than fetching from a network endpoint or reading from the filesystem.
295    ///
296    /// This is useful for client-side verification of DID documents received from a
297    /// cache server, where the raw log is transmitted alongside the resolved document
298    /// to enable independent verification without an additional network round-trip.
299    ///
300    /// # Arguments
301    /// * `did` — The DID to resolve (may include query parameters like `?versionId=...`).
302    /// * `log_entries` — Raw JSONL string containing one log entry per line.
303    /// * `witness_proofs` — Optional raw JSON string containing witness proofs.
304    ///
305    /// # Examples
306    /// ```no_run
307    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
308    /// use didwebvh_rs::DIDWebVHState;
309    ///
310    /// let raw_log = r#"{"versionId":"1-abc...","parameters":{...},...}"#;
311    /// let mut state = DIDWebVHState::default();
312    /// let (log_entry, metadata) = state
313    ///     .resolve_log("did:webvh:abc:example.com", raw_log, None)
314    ///     .await?;
315    /// # Ok(())
316    /// # }
317    /// ```
318    pub async fn resolve_log(
319        &mut self,
320        did: &str,
321        log_entries: &str,
322        witness_proofs: Option<&str>,
323    ) -> Result<(&LogEntry, MetaData), DIDWebVHError> {
324        let _span = span!(Level::DEBUG, "resolve_log", DID = did);
325        async move {
326            let parsed_did_url = WebVHURL::parse_did_url(did)?;
327
328            let parsed_entries = Self::parse_log_entries(log_entries)?;
329            Self::validate_log_entries(&parsed_entries, did)?;
330
331            let witness_collection = if let Some(raw_witnesses) = witness_proofs {
332                Self::parse_witness_proofs(raw_witnesses)?
333            } else {
334                WitnessProofCollection::default()
335            };
336
337            self.log_entries = parsed_entries;
338            self.witness_proofs = witness_collection;
339            self.validated = false;
340            self.expires = DateTime::default();
341
342            self.resolve_state(&parsed_did_url)
343        }
344        .instrument(_span)
345        .await
346    }
347
348    /// Like [`resolve_log()`](Self::resolve_log), but returns owned (cloned) values
349    /// so the caller does not borrow `self`.
350    pub async fn resolve_log_owned(
351        &mut self,
352        did: &str,
353        log_entries: &str,
354        witness_proofs: Option<&str>,
355    ) -> Result<(LogEntry, MetaData), DIDWebVHError> {
356        let (entry, metadata) = self.resolve_log(did, log_entries, witness_proofs).await?;
357        Ok((entry.clone(), metadata))
358    }
359}
360
361#[cfg(feature = "network")]
362impl DIDWebVHState {
363    /// Resolve witness proofs from a download result, applying the
364    /// "witnesses configured but download failed" policy.
365    fn resolve_witness_proofs(
366        raw_result: Result<String, DIDWebVHError>,
367        needs_witnesses: bool,
368    ) -> Result<WitnessProofCollection, DIDWebVHError> {
369        match raw_result {
370            Ok(raw) => Self::parse_witness_proofs(&raw),
371            Err(e) => {
372                if needs_witnesses {
373                    Err(DIDWebVHError::WitnessProofError(format!(
374                        "Witnesses are configured but witness proofs could not be downloaded: {e}"
375                    )))
376                } else {
377                    Ok(WitnessProofCollection::default())
378                }
379            }
380        }
381    }
382
383    /// Resolves a `did:webvh` DID by fetching its log entries and witness proofs over HTTP(S).
384    ///
385    /// Downloads `did.jsonl`, parses and validates all log entries, verifies witness
386    /// proofs against configured thresholds, and returns the resolved [`LogEntry`] with
387    /// [`MetaData`]. Results are cached until `self.expires`; subsequent calls reuse
388    /// the cached state unless expired.
389    ///
390    /// # Arguments
391    /// * `did` — The DID to resolve (may include query parameters like `?versionId=...`).
392    /// * `options` — Network options (timeout, eager witness download, max response size).
393    ///   Use [`ResolveOptions::default()`] for sensible defaults (10 s timeout, 200 KB limit).
394    ///
395    /// # Returned `LogEntry` vs. resolution-time DID Document
396    ///
397    /// The returned [`LogEntry`] carries `state` exactly as it was signed and
398    /// hashed — i.e. **without** the implicit `#files` / `#whois` services. To
399    /// obtain the resolution-time DID Document with the implicit services
400    /// injected (matching the `didDocument` shape returned by
401    /// `didwebvh-ts`'s `resolveDIDFromLog`), call
402    /// [`crate::log_entry::LogEntryMethods::get_did_document`] on the
403    /// returned entry. That method clones `state` and appends the
404    /// implicit services on the clone, so the LogEntry's stored `state` is
405    /// never mutated and the hash chain remains intact.
406    ///
407    /// **Never** feed the document returned by `get_did_document()` back into
408    /// a new LogEntry's `state` — doing so would bake the implicit services
409    /// into the canonical bytes and break interop with every other
410    /// implementation.
411    pub async fn resolve(
412        &mut self,
413        did: &str,
414        options: ResolveOptions,
415    ) -> Result<(&LogEntry, MetaData), DIDWebVHError> {
416        let _span = span!(Level::DEBUG, "resolve", DID = did);
417        async move {
418            let parsed_did_url = WebVHURL::parse_did_url(did)?;
419
420            if parsed_did_url.type_ == URLType::WhoIs {
421                return Err(DIDWebVHError::NotImplemented(
422                    "Resolving /whois URLs is not yet supported. Use the DID's #whois service endpoint directly.".to_string(),
423                ));
424            }
425
426            if !self.validated || self.expires < Utc::now() {
427                let max_bytes = options.max_response_bytes;
428
429                // If building for WASM then don't use tokio::spawn
430                // This means sequential retrieval of files
431                #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
432                let (log_entries, witness_proofs) = {
433                    trace!("timeout is not available in WASM builds! {:#?}", options.timeout);
434                    let client = reqwest::Client::new();
435
436                    let raw_entries =
437                        DIDWebVH::get_log_entries(parsed_did_url.clone(), client.clone(), max_bytes).await?;
438                    let log_entries = Self::parse_log_entries(&raw_entries)?;
439                    Self::validate_log_entries(&log_entries, did)?;
440
441                    let needs_witnesses = Self::needs_witness_proofs(&log_entries);
442                    let witness_proofs = if options.eager_witness_download || needs_witnesses {
443                        let raw_result =
444                            DIDWebVH::get_witness_proofs(parsed_did_url.clone(), client.clone(), max_bytes)
445                                .await;
446                        Self::resolve_witness_proofs(raw_result, needs_witnesses)?
447                    } else {
448                        WitnessProofCollection::default()
449                    };
450
451                    (log_entries, witness_proofs)
452                };
453
454                // Otherwise use tokio::spawn to do async downloads
455                #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
456                let (log_entries, witness_proofs) = {
457                    // Set network timeout values. Will default to 10 seconds for any reasons
458                    let network_timeout = options.timeout.unwrap_or(Duration::from_secs(10));
459
460                    let client = reqwest::ClientBuilder::new()
461                        .timeout(network_timeout)
462                        .redirect(reqwest::redirect::Policy::none())
463                        .build()
464                        .map_err(|e| DIDWebVHError::NetworkError {
465                            url: String::new(),
466                            status_code: None,
467                            message: format!("Failed to build HTTP client: {e}"),
468                        })?;
469
470                    if options.eager_witness_download {
471                        // Eager path: download both files concurrently
472                        let r1 = tokio::spawn(DIDWebVH::get_log_entries(
473                            parsed_did_url.clone(),
474                            client.clone(),
475                            max_bytes,
476                        ));
477                        let r2 = tokio::spawn(DIDWebVH::get_witness_proofs(
478                            parsed_did_url.clone(),
479                            client.clone(),
480                            max_bytes,
481                        ));
482
483                        let raw_entries = r1.await.map_err(|e| {
484                            DIDWebVHError::NetworkError {
485                                url: did.to_string(),
486                                status_code: None,
487                                message: format!("Error downloading LogEntries for DID: {e}"),
488                            }
489                        })??;
490                        let witness_result = match r2.await {
491                            Ok(result) => result,
492                            Err(_) => Ok("{}".to_string()),
493                        };
494
495                        let log_entries = Self::parse_log_entries(&raw_entries)?;
496                        Self::validate_log_entries(&log_entries, did)?;
497
498                        let needs_witnesses = Self::needs_witness_proofs(&log_entries);
499                        let witness_proofs =
500                            Self::resolve_witness_proofs(witness_result, needs_witnesses)?;
501
502                        (log_entries, witness_proofs)
503                    } else {
504                        // Deferred path: download did.jsonl first, then conditionally fetch witnesses
505                        let raw_entries = tokio::spawn(DIDWebVH::get_log_entries(
506                            parsed_did_url.clone(),
507                            client.clone(),
508                            max_bytes,
509                        ))
510                        .await
511                        .map_err(|e| DIDWebVHError::NetworkError {
512                            url: did.to_string(),
513                            status_code: None,
514                            message: format!("Error downloading LogEntries for DID: {e}"),
515                        })??;
516
517                        let log_entries = Self::parse_log_entries(&raw_entries)?;
518                        Self::validate_log_entries(&log_entries, did)?;
519
520                        let witness_proofs = if Self::needs_witness_proofs(&log_entries) {
521                            let raw_result = DIDWebVH::get_witness_proofs(
522                                parsed_did_url.clone(),
523                                client.clone(),
524                                max_bytes,
525                            )
526                            .await;
527                            Self::resolve_witness_proofs(raw_result, true)?
528                        } else {
529                            WitnessProofCollection::default()
530                        };
531
532                        (log_entries, witness_proofs)
533                    }
534                };
535
536                // Have LogEntries and Witness Proofs, now can validate the DID
537                self.log_entries = log_entries;
538                self.witness_proofs = witness_proofs;
539                self.validated = false;
540                self.expires = DateTime::default();
541            }
542
543            self.resolve_state(&parsed_did_url)
544        }
545        .instrument(_span)
546        .await
547    }
548
549    /// Like [`resolve()`](Self::resolve), but returns owned (cloned) values
550    /// so the caller does not borrow `self`.
551    pub async fn resolve_owned(
552        &mut self,
553        did: &str,
554        options: ResolveOptions,
555    ) -> Result<(LogEntry, MetaData), DIDWebVHError> {
556        let (entry, metadata) = self.resolve(did, options).await?;
557        Ok((entry.clone(), metadata))
558    }
559}
560
561impl DIDWebVHState {
562    fn resolve_state(
563        &mut self,
564        parsed_did_url: &WebVHURL,
565    ) -> Result<(&LogEntry, MetaData), DIDWebVHError> {
566        let _span = span!(Level::DEBUG, "resolve_state").entered();
567        // A resolver MUST reject a truncated log — a partial resolution is
568        // worse than no resolution because the caller cannot tell the
569        // difference. `assert_complete` surfaces the truncation as a
570        // `ValidationError`.
571        self.validate()?.assert_complete()?;
572
573        // Per spec (Read/Resolve step 6): the DID being resolved MUST match the
574        // top-level `id` in at least one version of the DIDDoc.
575        let resolved_did = parsed_did_url.to_did_base();
576        let did_matches_any = self.log_entries.iter().any(|entry| {
577            entry
578                .get_state()
579                .get("id")
580                .and_then(|v| v.as_str())
581                .is_some_and(|id| id == resolved_did)
582        });
583        if !did_matches_any {
584            return Err(DIDWebVHError::ValidationError(format!(
585                "DID being resolved ({resolved_did}) does not match the top-level 'id' in any DIDDoc version",
586            )));
587        }
588
589        // Ensure metadata is set for the DID
590        if let Some(first) = self.log_entries.first() {
591            self.scid = first
592                .get_scid()
593                .ok_or_else(|| {
594                    DIDWebVHError::ValidationError("First log entry is missing SCID".to_string())
595                })?
596                .to_string();
597            self.meta_first_ts = first.get_version_time_string();
598        }
599        if let Some(last) = self.log_entries.last() {
600            self.meta_last_ts = last.get_version_time_string();
601        }
602
603        // DID is fully validated
604        if parsed_did_url.query_version_id.is_some()
605            || parsed_did_url.query_version_time.is_some()
606            || parsed_did_url.query_version_number.is_some()
607        {
608            match self.get_specific_log_entry(
609                parsed_did_url.query_version_id.as_deref(),
610                parsed_did_url.query_version_time,
611                parsed_did_url.query_version_number,
612            ) {
613                Ok(entry) => {
614                    let metadata = self.generate_meta_data(entry);
615                    Ok((&entry.log_entry, metadata))
616                }
617                Err(e) => Err(DIDWebVHError::NotFound(format!(
618                    "Query matched no log entry: {e}"
619                ))),
620            }
621        } else if let Some(last) = self.log_entries.last() {
622            let metadata = self.generate_meta_data(last);
623            Ok((&last.log_entry, metadata))
624        } else {
625            Err(DIDWebVHError::NotFound(
626                "No LogEntries found after validation".to_string(),
627            ))
628        }
629    }
630}
631
632#[cfg(all(test, feature = "network"))]
633mod tests {
634    use super::ResolveOptions;
635    use crate::{DIDWebVHError, DIDWebVHState};
636
637    // ===== Mock-based resolve tests =====
638    //
639    // These tests create a DID locally and serve it via wiremock, so they
640    // run deterministically in CI without hitting any external servers.
641
642    use wiremock::{
643        Mock, MockServer, ResponseTemplate,
644        matchers::{any, path},
645    };
646
647    /// Helper: start a mock server, create a DID targeting its port, serialize
648    /// to JSONL, mount the mock response, and return `(server, did_url)`.
649    async fn setup_mock_resolve() -> (MockServer, String) {
650        use crate::test_utils::{did_doc_with_key, key_and_params};
651
652        let server = MockServer::start().await;
653        let port = server.address().port();
654
655        let (key, params) = key_and_params();
656        let did_template = format!("did:webvh:{{SCID}}:localhost%3A{port}");
657        let doc = did_doc_with_key(&did_template, &key);
658
659        let mut state = DIDWebVHState::default();
660        state
661            .create_log_entry(None, &doc, &params, &key)
662            .await
663            .expect("Failed to create log entry");
664
665        let log_entry = &state.log_entries[0].log_entry;
666        let jsonl = serde_json::to_string(log_entry).unwrap();
667        let scid = state.scid();
668        let did = format!("did:webvh:{scid}:localhost%3A{port}");
669
670        Mock::given(path("/.well-known/did.jsonl"))
671            .respond_with(ResponseTemplate::new(200).set_body_string(&jsonl))
672            .mount(&server)
673            .await;
674
675        (server, did)
676    }
677
678    /// Resolve a DID served from a local mock server.
679    #[tokio::test]
680    async fn resolve_mock() {
681        let (_server, did) = setup_mock_resolve().await;
682
683        let mut webvh = DIDWebVHState::default();
684        let result = webvh.resolve(&did, ResolveOptions::default()).await;
685        assert!(result.is_ok(), "resolve failed: {result:?}");
686    }
687
688    /// Resolve with eager witness download (no witnesses configured).
689    #[tokio::test]
690    async fn resolve_mock_eager() {
691        let (server, did) = setup_mock_resolve().await;
692
693        // Witness file returns 404 — that's fine, no witnesses configured
694        Mock::given(path("/.well-known/did-witness.json"))
695            .respond_with(ResponseTemplate::new(404))
696            .mount(&server)
697            .await;
698
699        let mut webvh = DIDWebVHState::default();
700        let result = webvh
701            .resolve(
702                &did,
703                ResolveOptions {
704                    eager_witness_download: true,
705                    ..ResolveOptions::default()
706                },
707            )
708            .await;
709        assert!(result.is_ok(), "eager resolve failed: {result:?}");
710    }
711
712    /// Resolve a specific versionId served from a local mock server.
713    #[tokio::test]
714    async fn resolve_mock_specific_version() {
715        use crate::log_entry::LogEntryMethods;
716
717        let (_server, did) = setup_mock_resolve().await;
718
719        // First resolve to get the versionId
720        let mut webvh = DIDWebVHState::default();
721        let (entry, _) = webvh
722            .resolve(&did, ResolveOptions::default())
723            .await
724            .unwrap();
725        let version_id = entry.get_version_id().to_string();
726
727        // Resolve again with ?versionId=...
728        let mut webvh2 = DIDWebVHState::default();
729        let did_with_version = format!("{did}?versionId={version_id}");
730        let result = webvh2
731            .resolve(&did_with_version, ResolveOptions::default())
732            .await;
733        assert!(result.is_ok(), "versionId resolve failed: {result:?}");
734    }
735
736    /// Resolve with ?versionTime query from a local mock server.
737    #[tokio::test]
738    async fn resolve_mock_specific_time() {
739        use crate::log_entry::LogEntryMethods;
740
741        let (_server, did) = setup_mock_resolve().await;
742
743        // Resolve to get a valid versionTime
744        let mut webvh = DIDWebVHState::default();
745        let (entry, _) = webvh
746            .resolve(&did, ResolveOptions::default())
747            .await
748            .unwrap();
749        let version_time = entry.get_version_time_string();
750
751        // Resolve again with ?versionTime=...
752        let mut webvh2 = DIDWebVHState::default();
753        let did_with_time = format!("{did}?versionTime={version_time}");
754        let result = webvh2
755            .resolve(&did_with_time, ResolveOptions::default())
756            .await;
757        assert!(result.is_ok(), "versionTime resolve failed: {result:?}");
758    }
759
760    // ===== Network failure tests =====
761    //
762    // These tests use wiremock to simulate HTTP failures without hitting real servers.
763    // DIDs pointing to `localhost` use `http://` (not HTTPS), allowing local mock servers.
764
765    /// Helper: build a DID URL pointing at the given wiremock server.
766    /// Format: `did:webvh:<scid>:localhost%3A<port>`
767    fn mock_did(server: &wiremock::MockServer, scid: &str) -> String {
768        let port = server.address().port();
769        format!("did:webvh:{scid}:localhost%3A{port}")
770    }
771
772    /// Tests that resolving against a server that returns HTTP 404 produces a
773    /// NetworkError with status_code = Some(404).
774    #[tokio::test]
775    async fn resolve_http_404() {
776        let server = MockServer::start().await;
777        Mock::given(any())
778            .respond_with(ResponseTemplate::new(404))
779            .mount(&server)
780            .await;
781
782        let did = mock_did(&server, "testscid404");
783        let mut webvh = DIDWebVHState::default();
784        let result = webvh.resolve(&did, ResolveOptions::default()).await;
785
786        match result {
787            Err(DIDWebVHError::NetworkError {
788                status_code: Some(404),
789                ..
790            }) => {} // expected
791            other => panic!("Expected NetworkError with status 404, got: {other:?}"),
792        }
793    }
794
795    /// Tests that resolving against a server that returns HTTP 500 produces a
796    /// NetworkError with status_code = Some(500).
797    #[tokio::test]
798    async fn resolve_http_500() {
799        let server = MockServer::start().await;
800        Mock::given(any())
801            .respond_with(ResponseTemplate::new(500))
802            .mount(&server)
803            .await;
804
805        let did = mock_did(&server, "testscid500");
806        let mut webvh = DIDWebVHState::default();
807        let result = webvh.resolve(&did, ResolveOptions::default()).await;
808
809        match result {
810            Err(DIDWebVHError::NetworkError {
811                status_code: Some(500),
812                ..
813            }) => {}
814            other => panic!("Expected NetworkError with status 500, got: {other:?}"),
815        }
816    }
817
818    /// Tests that resolving against a server that returns 200 with invalid JSON
819    /// (not valid JSONL log entries) produces a deserialization error.
820    #[tokio::test]
821    async fn resolve_malformed_response() {
822        let server = MockServer::start().await;
823        Mock::given(any())
824            .respond_with(ResponseTemplate::new(200).set_body_string("this is not jsonl"))
825            .mount(&server)
826            .await;
827
828        let did = mock_did(&server, "testscidbad");
829        let mut webvh = DIDWebVHState::default();
830        let result = webvh.resolve(&did, ResolveOptions::default()).await;
831
832        match result {
833            Err(DIDWebVHError::LogEntryError(_)) => {} // expected: invalid JSON
834            other => panic!("Expected LogEntryError for malformed response body, got: {other:?}"),
835        }
836    }
837
838    /// Tests that resolving against a server that returns 200 with an empty body
839    /// produces a NotFound error (no log entries).
840    #[tokio::test]
841    async fn resolve_empty_response() {
842        let server = MockServer::start().await;
843        Mock::given(any())
844            .respond_with(ResponseTemplate::new(200).set_body_string(""))
845            .mount(&server)
846            .await;
847
848        let did = mock_did(&server, "testscidempty");
849        let mut webvh = DIDWebVHState::default();
850        let result = webvh.resolve(&did, ResolveOptions::default()).await;
851
852        match result {
853            Err(DIDWebVHError::NotFound(msg)) => {
854                assert!(
855                    msg.contains("No LogEntries"),
856                    "Expected 'No LogEntries' message, got: {msg}"
857                );
858            }
859            other => panic!("Expected NotFound error, got: {other:?}"),
860        }
861    }
862
863    /// Tests that a network timeout is surfaced as a NetworkError with no status_code.
864    #[tokio::test]
865    async fn resolve_timeout() {
866        use std::time::Duration;
867        let server = MockServer::start().await;
868        // Respond after 5 seconds — longer than our 1-second timeout
869        Mock::given(any())
870            .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5)))
871            .mount(&server)
872            .await;
873
874        let did = mock_did(&server, "testscidtimeout");
875        let mut webvh = DIDWebVHState::default();
876        let result = webvh
877            .resolve(
878                &did,
879                ResolveOptions {
880                    timeout: Some(Duration::from_secs(1)),
881                    ..ResolveOptions::default()
882                },
883            )
884            .await;
885
886        match result {
887            Err(DIDWebVHError::NetworkError {
888                status_code: None, ..
889            }) => {} // transport-level timeout, no HTTP status
890            other => panic!("Expected NetworkError with no status_code (timeout), got: {other:?}"),
891        }
892    }
893
894    /// Tests that connection refused (no server listening) produces a NetworkError
895    /// with no status_code.
896    #[tokio::test]
897    async fn resolve_connection_refused() {
898        // Use a port where nothing is listening
899        let did = "did:webvh:testscidrefused:localhost%3A1";
900        let mut webvh = DIDWebVHState::default();
901        let result = webvh
902            .resolve(
903                did,
904                ResolveOptions {
905                    timeout: Some(std::time::Duration::from_secs(2)),
906                    ..ResolveOptions::default()
907                },
908            )
909            .await;
910
911        match result {
912            Err(DIDWebVHError::NetworkError {
913                status_code: None, ..
914            }) => {}
915            other => panic!(
916                "Expected NetworkError with no status_code (connection refused), got: {other:?}"
917            ),
918        }
919    }
920
921    /// Tests that the structured NetworkError fields are correctly populated
922    /// (url contains the expected host, status_code and message are set).
923    #[tokio::test]
924    async fn resolve_network_error_fields() {
925        let server = MockServer::start().await;
926        Mock::given(any())
927            .respond_with(ResponseTemplate::new(503))
928            .mount(&server)
929            .await;
930
931        let did = mock_did(&server, "testscidfields");
932        let mut webvh = DIDWebVHState::default();
933        let result = webvh.resolve(&did, ResolveOptions::default()).await;
934
935        match result {
936            Err(DIDWebVHError::NetworkError {
937                ref url,
938                status_code,
939                ref message,
940            }) => {
941                assert!(
942                    url.contains("localhost"),
943                    "url should contain localhost: {url}"
944                );
945                assert_eq!(status_code, Some(503));
946                assert!(
947                    message.contains("503"),
948                    "message should contain status: {message}"
949                );
950            }
951            other => panic!("Expected structured NetworkError, got: {other:?}"),
952        }
953    }
954
955    // ===== resolve_log tests =====
956
957    /// Helper: create a DID with log entries and return (did_string, jsonl_string)
958    async fn setup_resolve_log_data() -> (String, String) {
959        use crate::test_utils::{did_doc_with_key, key_and_params};
960
961        let server = MockServer::start().await;
962        let port = server.address().port();
963
964        let (key, params) = key_and_params();
965        let did_template = format!("did:webvh:{{SCID}}:localhost%3A{port}");
966        let doc = did_doc_with_key(&did_template, &key);
967
968        let mut state = DIDWebVHState::default();
969        state
970            .create_log_entry(None, &doc, &params, &key)
971            .await
972            .expect("Failed to create log entry");
973
974        let log_entry = &state.log_entries[0].log_entry;
975        let jsonl = serde_json::to_string(log_entry).unwrap();
976        let scid = state.scid();
977        let did = format!("did:webvh:{scid}:localhost%3A{port}");
978
979        (did, jsonl)
980    }
981
982    /// Resolve a DID from raw JSONL log data (no network needed for verification).
983    #[tokio::test]
984    async fn resolve_log_from_str() {
985        let (did, jsonl) = setup_resolve_log_data().await;
986
987        let mut webvh = DIDWebVHState::default();
988        let result = webvh.resolve_log(&did, &jsonl, None).await;
989        assert!(result.is_ok(), "resolve_log failed: {result:?}");
990    }
991
992    /// Resolve a DID from raw JSONL log data and verify the returned document
993    /// matches what was resolved via network.
994    #[tokio::test]
995    async fn resolve_log_matches_network_resolve() {
996        use crate::log_entry::LogEntryMethods;
997
998        let (server, did) = setup_mock_resolve().await;
999        // Also need witness endpoint for eager
1000        Mock::given(path("/.well-known/did-witness.json"))
1001            .respond_with(ResponseTemplate::new(404))
1002            .mount(&server)
1003            .await;
1004
1005        // Resolve via network
1006        let mut webvh_net = DIDWebVHState::default();
1007        let (net_entry, _) = webvh_net
1008            .resolve(&did, ResolveOptions::default())
1009            .await
1010            .unwrap();
1011        let net_doc = net_entry.get_did_document().unwrap();
1012
1013        // Get the raw log from the network-resolved state
1014        let log_entry = &webvh_net.log_entries()[0].log_entry;
1015        let jsonl = serde_json::to_string(log_entry).unwrap();
1016
1017        // Resolve via resolve_log
1018        let mut webvh_log = DIDWebVHState::default();
1019        let (log_entry, _) = webvh_log.resolve_log(&did, &jsonl, None).await.unwrap();
1020        let log_doc = log_entry.get_did_document().unwrap();
1021
1022        assert_eq!(
1023            net_doc, log_doc,
1024            "Documents from network and log resolution should match"
1025        );
1026    }
1027
1028    /// resolve_log_owned returns owned values without borrowing self.
1029    #[tokio::test]
1030    async fn resolve_log_owned_works() {
1031        let (did, jsonl) = setup_resolve_log_data().await;
1032
1033        let mut webvh = DIDWebVHState::default();
1034        let result = webvh.resolve_log_owned(&did, &jsonl, None).await;
1035        assert!(result.is_ok(), "resolve_log_owned failed: {result:?}");
1036    }
1037
1038    /// resolve_log rejects empty log data.
1039    #[tokio::test]
1040    async fn resolve_log_empty_log() {
1041        let mut webvh = DIDWebVHState::default();
1042        let result = webvh
1043            .resolve_log("did:webvh:testscid:example.com", "", None)
1044            .await;
1045
1046        match result {
1047            Err(DIDWebVHError::NotFound(msg)) => {
1048                assert!(
1049                    msg.contains("No LogEntries"),
1050                    "Expected 'No LogEntries' message, got: {msg}"
1051                );
1052            }
1053            other => panic!("Expected NotFound error, got: {other:?}"),
1054        }
1055    }
1056
1057    /// resolve_log rejects malformed JSONL.
1058    #[tokio::test]
1059    async fn resolve_log_malformed_jsonl() {
1060        let mut webvh = DIDWebVHState::default();
1061        let result = webvh
1062            .resolve_log("did:webvh:testscid:example.com", "not valid json", None)
1063            .await;
1064
1065        match result {
1066            Err(DIDWebVHError::LogEntryError(_)) => {} // expected
1067            other => panic!("Expected LogEntryError, got: {other:?}"),
1068        }
1069    }
1070
1071    /// resolve_log with tampered log data should fail validation.
1072    #[tokio::test]
1073    async fn resolve_log_tampered_data_fails() {
1074        let (did, jsonl) = setup_resolve_log_data().await;
1075
1076        // Tamper with the JSONL by modifying a character in the proof/signature
1077        // This should cause validation to fail
1078        let tampered = jsonl.replacen("z", "y", 1);
1079        if tampered == jsonl {
1080            // If no 'z' was found, skip the test
1081            return;
1082        }
1083
1084        let mut webvh = DIDWebVHState::default();
1085        let result = webvh.resolve_log(&did, &tampered, None).await;
1086        assert!(
1087            result.is_err(),
1088            "resolve_log should fail with tampered data"
1089        );
1090    }
1091
1092    // ===== Response size limit tests =====
1093
1094    /// Tests that a response with a body exceeding the limit is rejected.
1095    #[tokio::test]
1096    async fn resolve_rejects_large_response_body() {
1097        let server = MockServer::start().await;
1098        // Create a body larger than DEFAULT_MAX_RESPONSE_BYTES (200 KB)
1099        let large_body = "x".repeat(210 * 1024);
1100        Mock::given(any())
1101            .respond_with(ResponseTemplate::new(200).set_body_string(&large_body))
1102            .mount(&server)
1103            .await;
1104
1105        let did = mock_did(&server, "testscidlarge");
1106        let mut webvh = DIDWebVHState::default();
1107        let result = webvh.resolve(&did, ResolveOptions::default()).await;
1108
1109        match result {
1110            Err(DIDWebVHError::ResponseTooLarge { max_bytes, .. }) => {
1111                assert_eq!(max_bytes, super::DEFAULT_MAX_RESPONSE_BYTES);
1112            }
1113            other => panic!("Expected ResponseTooLarge, got: {other:?}"),
1114        }
1115    }
1116
1117    /// Tests that a custom max_response_bytes limit is enforced.
1118    #[tokio::test]
1119    async fn resolve_custom_size_limit() {
1120        let (_server, did) = setup_mock_resolve().await;
1121
1122        // Use an extremely small limit that the valid response will exceed
1123        let mut webvh = DIDWebVHState::default();
1124        let result = webvh
1125            .resolve(
1126                &did,
1127                ResolveOptions {
1128                    max_response_bytes: 10, // 10 bytes — too small for any DID log
1129                    ..ResolveOptions::default()
1130                },
1131            )
1132            .await;
1133
1134        match result {
1135            Err(DIDWebVHError::ResponseTooLarge { max_bytes, .. }) => {
1136                assert_eq!(max_bytes, 10);
1137            }
1138            other => panic!("Expected ResponseTooLarge, got: {other:?}"),
1139        }
1140    }
1141
1142    /// Tests that normal-sized responses pass the default size limit.
1143    #[tokio::test]
1144    async fn resolve_normal_response_passes_size_check() {
1145        let (_server, did) = setup_mock_resolve().await;
1146
1147        let mut webvh = DIDWebVHState::default();
1148        let result = webvh.resolve(&did, ResolveOptions::default()).await;
1149        assert!(
1150            result.is_ok(),
1151            "Normal response should pass size check: {result:?}"
1152        );
1153    }
1154}