Skip to main content

trql_client/
client.rs

1//! The typed query client: builds request documents, delegates the exchange to
2//! a [`TrqlTransport`], and owns all document semantics (correlation, error
3//! mapping, payload typing) so every binding behaves identically.
4
5use std::sync::Arc;
6
7use chrono::{DateTime, Utc};
8use serde::Serialize;
9use serde::de::DeserializeOwned;
10use serde_json::Value;
11use trust_tasks_rs::{ErrorPayload, Payload, TrustTask};
12
13use crate::error::TrqlError;
14use crate::payloads::{
15    AuthorizationRequest, AuthorizationResponse, RecognitionRequest, RecognitionResponse,
16};
17use crate::transport::TrqlTransport;
18
19/// Slug of the framework's reserved error document type.
20const ERROR_SLUG: &str = "trust-task-error";
21
22/// Map a generated-payload builder failure onto [`TrqlError::Config`].
23///
24/// The generated request types are `#[non_exhaustive]`, so they are assembled
25/// through their builders, which validate at `try_into()`. Every field this
26/// client sets is a required one it always supplies, so a failure here means
27/// the client and the spec crate disagree about the payload shape — a build-
28/// time contract problem, not anything the peer did.
29fn payload_build_error(err: impl std::fmt::Display) -> TrqlError {
30    TrqlError::Config(format!("could not build query payload: {err}"))
31}
32
33/// The TRQP 4-tuple members, named identically on requests and responses.
34///
35/// Both `registry/authorization/0.1` and `registry/recognition/0.1` require all
36/// four on the response, verbatim from the request, which is what makes an echo
37/// check possible without knowing which query is in flight.
38const TRQP_TUPLE: [&str; 4] = ["entity_id", "authority_id", "action", "resource"];
39
40/// The TRQP 4-tuple every registry query is keyed on, plus the optional
41/// evaluation context.
42#[derive(Debug, Clone)]
43pub struct TrqpQuery {
44    /// DID of the entity whose trust is being checked.
45    pub entity_id: String,
46    /// DID of the governing authority.
47    pub authority_id: String,
48    /// The action being checked (e.g. `issue`, `git.commit.sign`).
49    pub action: String,
50    /// The resource the action applies to.
51    pub resource: String,
52    /// Evaluate as of this instant instead of "now", when set.
53    pub time: Option<DateTime<Utc>>,
54    /// Authority-defined record-location hint.
55    pub locator: Option<String>,
56}
57
58impl TrqpQuery {
59    /// A query over the TRQP 4-tuple, evaluated at the registry's current time.
60    pub fn new(
61        entity_id: impl Into<String>,
62        authority_id: impl Into<String>,
63        action: impl Into<String>,
64        resource: impl Into<String>,
65    ) -> Self {
66        Self {
67            entity_id: entity_id.into(),
68            authority_id: authority_id.into(),
69            action: action.into(),
70            resource: resource.into(),
71            time: None,
72            locator: None,
73        }
74    }
75
76    /// Request evaluation as of `time` instead of the registry's current time.
77    pub fn at(mut self, time: DateTime<Utc>) -> Self {
78        self.time = Some(time);
79        self
80    }
81
82    /// Attach an authority-defined locator hint.
83    pub fn locator(mut self, locator: impl Into<String>) -> Self {
84        self.locator = Some(locator.into());
85        self
86    }
87
88    fn has_context(&self) -> bool {
89        self.time.is_some() || self.locator.is_some()
90    }
91}
92
93/// Transport-agnostic Trust Registry query client.
94///
95/// Holds one [`TrqlTransport`] and the registry's DID (the `recipient` stamped
96/// on every request — the registry rejects documents addressed to anyone
97/// else). Swapping HTTPS for DIDComm or TSP changes only the transport handed
98/// to [`TrqlClient::new`].
99pub struct TrqlClient {
100    transport: Arc<dyn TrqlTransport>,
101    registry_did: String,
102    client_did: Option<String>,
103    referred_by: Option<String>,
104}
105
106impl TrqlClient {
107    /// A client that queries the registry identified by `registry_did` over
108    /// `transport`.
109    pub fn new(transport: Arc<dyn TrqlTransport>, registry_did: impl Into<String>) -> Self {
110        Self {
111            transport,
112            registry_did: registry_did.into(),
113            client_did: None,
114            referred_by: None,
115        }
116    }
117
118    /// Set the in-band `issuer` on outbound documents. Optional over HTTPS
119    /// (the registry treats queries as anonymous reads); DIDComm and TSP
120    /// authenticate the sender at the transport layer regardless.
121    pub fn with_client_did(mut self, did: impl Into<String>) -> Self {
122        self.client_did = Some(did.into());
123        self
124    }
125
126    /// Record that this client reached the registry by following a
127    /// [`crate::registry_referral`] in `origin_did`'s document, and hold every
128    /// answer to that provenance.
129    ///
130    /// This is step 5 of `DID_SERVICE_DISCOVERY.md` §4 — *closing the loop* —
131    /// made structural instead of advisory. With it set, an answer whose
132    /// `authority_id` is anything other than `origin_did` is rejected as
133    /// [`TrqlError::ReferralNotClosed`] rather than returned.
134    ///
135    /// A referral is a self-assertion: anyone can publish a DID document
136    /// naming any registry, so a followed referral establishes *where to ask*
137    /// and nothing about the answer. Authority flows registry → subject, and
138    /// only the registry answering for `origin_did` completes that direction.
139    ///
140    /// ```rust,ignore
141    /// let mut doc = resolve(vtc_did).await?;
142    /// let client = match registry_referral(&doc) {
143    ///     Some(registry) => {
144    ///         doc = resolve(&registry).await?;              // one hop
145    ///         TrqlClient::new(transport, registry).referred_by(vtc_did)
146    ///     }
147    ///     None => TrqlClient::new(transport, registry_did),
148    /// };
149    /// ```
150    pub fn referred_by(mut self, origin_did: impl Into<String>) -> Self {
151        self.referred_by = Some(origin_did.into());
152        self
153    }
154
155    /// Ask whether `entity` is authorized by `authority` for `action` on
156    /// `resource` (`registry/authorization/0.1`).
157    ///
158    /// A registry with no matching record answers `authorized: false` — absence
159    /// is a denial, not an error.
160    pub async fn authorization(
161        &self,
162        query: TrqpQuery,
163    ) -> Result<AuthorizationResponse, TrqlError> {
164        let context: Option<crate::payloads::AuthorizationQueryContext> = query
165            .has_context()
166            .then(|| {
167                crate::payloads::AuthorizationQueryContext::builder()
168                    .time(query.time)
169                    .locator(query.locator.clone())
170                    .extra(std::collections::HashMap::new())
171                    .try_into()
172            })
173            .transpose()
174            .map_err(payload_build_error)?;
175        let payload: AuthorizationRequest = AuthorizationRequest::builder()
176            .entity_id(query.entity_id.clone())
177            .authority_id(query.authority_id.clone())
178            .action(query.action.clone())
179            .resource(query.resource.clone())
180            .context(context)
181            .try_into()
182            .map_err(payload_build_error)?;
183        self.send_query(payload).await
184    }
185
186    /// Ask whether `entity` is recognized by `authority` for `action` on
187    /// `resource` (`registry/recognition/0.1`).
188    pub async fn recognition(&self, query: TrqpQuery) -> Result<RecognitionResponse, TrqlError> {
189        let context: Option<crate::payloads::RecognitionQueryContext> = query
190            .has_context()
191            .then(|| {
192                crate::payloads::RecognitionQueryContext::builder()
193                    .time(query.time)
194                    .locator(query.locator.clone())
195                    .extra(std::collections::HashMap::new())
196                    .try_into()
197            })
198            .transpose()
199            .map_err(payload_build_error)?;
200        let payload: RecognitionRequest = RecognitionRequest::builder()
201            .entity_id(query.entity_id.clone())
202            .authority_id(query.authority_id.clone())
203            .action(query.action.clone())
204            .resource(query.resource.clone())
205            .context(context)
206            .try_into()
207            .map_err(payload_build_error)?;
208        self.send_query(payload).await
209    }
210
211    /// Build the request document for `payload`, run one exchange, and
212    /// validate the reply: correlated (`threadId` == request `id`), then
213    /// either the matching `#response` document or a `trust-task-error`
214    /// mapped to [`TrqlError::Rejected`]. A `#response` must additionally
215    /// answer the tuple that was asked, and close any referral this client was
216    /// built with.
217    async fn send_query<Req, Resp>(&self, payload: Req) -> Result<Resp, TrqlError>
218    where
219        Req: Payload + Serialize,
220        Resp: DeserializeOwned,
221    {
222        let body = serde_json::to_value(&payload)
223            .map_err(|e| TrqlError::Contract(format!("request payload did not serialize: {e}")))?;
224        // Capture the tuple before `body` is moved into the request document;
225        // the reply is checked against what we actually put on the wire.
226        let asked = TRQP_TUPLE.map(|field| body.get(field).cloned().unwrap_or(Value::Null));
227        let id = new_task_id();
228        let mut request = TrustTask::new(id.clone(), Req::type_uri(), body);
229        request.recipient = Some(self.registry_did.clone());
230        request.issuer = self.client_did.clone();
231        request.issued_at = Some(Utc::now());
232        let request_slug = request.type_uri.slug().to_string();
233
234        let reply = self.transport.exchange(request).await?;
235
236        // Correlation is checked, never assumed: an uncorrelated document is
237        // someone else's reply and must not be interpreted as ours.
238        if reply.thread_id.as_deref() != Some(id.as_str()) {
239            return Err(TrqlError::Contract(format!(
240                "uncorrelated reply: threadId {:?} does not match request id {id}",
241                reply.thread_id
242            )));
243        }
244
245        if reply.type_uri.slug() == ERROR_SLUG {
246            let error: ErrorPayload = serde_json::from_value(reply.payload).map_err(|e| {
247                TrqlError::Contract(format!("trust-task-error payload did not parse: {e}"))
248            })?;
249            return Err(TrqlError::Rejected {
250                code: error.code,
251                retryable: error.retryable,
252                retry_after: error.retry_after,
253                message: error.message,
254            });
255        }
256
257        if !(reply.type_uri.is_response() && reply.type_uri.slug() == request_slug) {
258            return Err(TrqlError::Contract(format!(
259                "unexpected reply type `{}` to a `{request_slug}` request",
260                reply.type_uri
261            )));
262        }
263
264        self.check_answers_our_question(&asked, &reply.payload)?;
265
266        serde_json::from_value(reply.payload)
267            .map_err(|e| TrqlError::Contract(format!("response payload did not parse: {e}")))
268    }
269
270    /// Reject a reply that answers a different question than the one asked, or
271    /// that fails to close the referral this client followed.
272    ///
273    /// Correlation (`threadId`) proves a reply belongs to our exchange but not
274    /// what it is an answer *about*. TRQP responses echo the queried tuple
275    /// precisely so the asker can tell; nothing checked that echo before, so a
276    /// registry — or anything able to shape its reply — could substitute the
277    /// subject of an authorization decision and be believed.
278    ///
279    /// A field the reply omits is *not* treated as a mismatch here: all four
280    /// members are required by the response schema, so absence fails in the
281    /// deserialization immediately below. That keeps a malformed payload
282    /// reported as a contract violation rather than as a substituted answer,
283    /// and either way the query fails closed.
284    fn check_answers_our_question(
285        &self,
286        asked: &[Value; TRQP_TUPLE.len()],
287        answered: &Value,
288    ) -> Result<(), TrqlError> {
289        for (&field, asked) in TRQP_TUPLE.iter().zip(asked) {
290            let Some(answered) = answered.get(field) else {
291                continue;
292            };
293            if answered != asked {
294                return Err(TrqlError::AnswerMismatch {
295                    field,
296                    asked: render(asked),
297                    answered: render(answered),
298                });
299            }
300        }
301
302        // Closing the loop (DID_SERVICE_DISCOVERY.md §4 step 5). The echo check
303        // above proves the registry answered the authority we named; this
304        // proves the authority we named is the one that referred us here. A
305        // caller that follows a referral and then asks about some other
306        // authority has learned nothing about the referral.
307        if let Some(origin) = self.referred_by.as_deref() {
308            let answered = answered.get("authority_id").and_then(Value::as_str);
309            if answered != Some(origin) {
310                return Err(TrqlError::ReferralNotClosed {
311                    origin: origin.to_string(),
312                    answered: answered.unwrap_or("<absent>").to_string(),
313                });
314            }
315        }
316
317        Ok(())
318    }
319}
320
321/// Render a tuple member for an error message.
322///
323/// These are strings on the wire, so the common case prints bare; anything
324/// else prints as the JSON it actually was, which is the detail an operator
325/// needs when a registry answers with the wrong shape rather than the wrong
326/// value.
327fn render(value: &Value) -> String {
328    match value.as_str() {
329        Some(s) => s.to_string(),
330        None => value.to_string(),
331    }
332}
333
334/// A fresh `urn:uuid:` document id.
335fn new_task_id() -> String {
336    format!("urn:uuid:{}", uuid_v4())
337}
338
339#[cfg(any(feature = "didcomm", feature = "tsp"))]
340fn uuid_v4() -> String {
341    uuid::Uuid::new_v4().to_string()
342}
343
344// Without the mediator transports the crate has no uuid dependency; derive the
345// id from entropy the std library already gives us. Uniqueness only needs to
346// hold within this process's in-flight queries.
347#[cfg(not(any(feature = "didcomm", feature = "tsp")))]
348fn uuid_v4() -> String {
349    use std::sync::atomic::{AtomicU64, Ordering};
350    use std::time::{SystemTime, UNIX_EPOCH};
351    static COUNTER: AtomicU64 = AtomicU64::new(0);
352    let nanos = SystemTime::now()
353        .duration_since(UNIX_EPOCH)
354        .map(|d| d.as_nanos())
355        .unwrap_or(0);
356    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
357    format!("{nanos:032x}-{n:016x}")
358}
359
360#[cfg(test)]
361mod tests {
362    #![allow(clippy::unwrap_used, clippy::expect_used)]
363
364    use super::*;
365    use trust_tasks_rs::RejectReason;
366
367    /// A transport that answers every request with a canned closure.
368    struct MockTransport<F>(F)
369    where
370        F: Fn(TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> + Send + Sync;
371
372    #[async_trait::async_trait]
373    impl<F> TrqlTransport for MockTransport<F>
374    where
375        F: Fn(TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> + Send + Sync,
376    {
377        fn kind(&self) -> crate::TransportKind {
378            crate::TransportKind::Https
379        }
380
381        async fn exchange(&self, request: TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> {
382            (self.0)(request)
383        }
384    }
385
386    fn client_over<F>(f: F) -> TrqlClient
387    where
388        F: Fn(TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> + Send + Sync + 'static,
389    {
390        TrqlClient::new(Arc::new(MockTransport(f)), "did:example:registry")
391    }
392
393    fn authorized_response(request: &TrustTask<Value>, authorized: bool) -> TrustTask<Value> {
394        let payload = serde_json::json!({
395            "entity_id": request.payload["entity_id"],
396            "authority_id": request.payload["authority_id"],
397            "action": request.payload["action"],
398            "resource": request.payload["resource"],
399            "authorized": authorized,
400            "time_evaluated": "2026-07-16T00:00:00Z",
401        });
402        request.respond_with("urn:uuid:reply".to_string(), payload)
403    }
404
405    fn query() -> TrqpQuery {
406        TrqpQuery::new("did:example:e", "did:example:a", "issue", "vc")
407    }
408
409    #[tokio::test]
410    async fn authorization_round_trip_stamps_recipient_and_parses_reply() {
411        let client = client_over(|req| {
412            assert_eq!(req.recipient.as_deref(), Some("did:example:registry"));
413            assert!(req.issued_at.is_some());
414            assert_eq!(req.type_uri.slug(), "registry/authorization");
415            Ok(authorized_response(&req, true))
416        });
417        let response = client.authorization(query()).await.unwrap();
418        assert!(response.authorized);
419        assert_eq!(response.entity_id, "did:example:e");
420    }
421
422    #[tokio::test]
423    async fn uncorrelated_reply_is_a_contract_error() {
424        let client = client_over(|req| {
425            let mut reply = authorized_response(&req, true);
426            reply.thread_id = Some("urn:uuid:someone-else".to_string());
427            Ok(reply)
428        });
429        let err = client.authorization(query()).await.unwrap_err();
430        assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
431        assert!(!err.is_retryable());
432    }
433
434    #[tokio::test]
435    async fn error_document_maps_to_rejected_with_code() {
436        let client = client_over(|req| {
437            let error_doc = req.reject_with(
438                "urn:uuid:err".to_string(),
439                RejectReason::PermissionDenied {
440                    reason: "not allowed".to_string(),
441                },
442            );
443            // The seam carries untyped documents; reserialize like a wire hop.
444            let as_value = serde_json::to_value(&error_doc).unwrap();
445            Ok(serde_json::from_value(as_value).unwrap())
446        });
447        let err = client.authorization(query()).await.unwrap_err();
448        match err {
449            TrqlError::Rejected { retryable, .. } => assert!(!retryable),
450            other => panic!("expected Rejected, got {other}"),
451        }
452    }
453
454    #[tokio::test]
455    async fn wrong_response_type_is_a_contract_error() {
456        let client = client_over(|req| {
457            let mut reply = authorized_response(&req, true);
458            reply.type_uri = "https://trusttasks.org/spec/registry/recognition/0.1#response"
459                .parse()
460                .unwrap();
461            Ok(reply)
462        });
463        let err = client.authorization(query()).await.unwrap_err();
464        assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
465    }
466
467    #[tokio::test]
468    async fn malformed_response_payload_is_a_contract_error_not_transport() {
469        let client = client_over(|req| {
470            // `authorized` missing: the strict payload must fail to parse.
471            let payload = serde_json::json!({ "unexpected": true });
472            Ok(req.respond_with("urn:uuid:reply".to_string(), payload))
473        });
474        let err = client.authorization(query()).await.unwrap_err();
475        assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
476        assert!(!err.is_retryable());
477    }
478
479    #[tokio::test]
480    async fn recognition_parses_recognized_flag() {
481        let client = client_over(|req| {
482            let payload = serde_json::json!({
483                "entity_id": req.payload["entity_id"],
484                "authority_id": req.payload["authority_id"],
485                "action": req.payload["action"],
486                "resource": req.payload["resource"],
487                "recognized": false,
488                "time_evaluated": "2026-07-16T00:00:00Z",
489            });
490            Ok(req.respond_with("urn:uuid:reply".to_string(), payload))
491        });
492        let response = client.recognition(query()).await.unwrap();
493        assert!(!response.recognized, "absence of trust reads as false");
494    }
495
496    #[tokio::test]
497    async fn query_context_is_sent_when_time_is_set() {
498        let at = "2026-01-01T00:00:00Z".parse().unwrap();
499        let client = client_over(|req| {
500            assert_eq!(
501                req.payload["context"]["time"], "2026-01-01T00:00:00Z",
502                "context.time must be carried on the wire"
503            );
504            Ok(authorized_response(&req, true))
505        });
506        client.authorization(query().at(at)).await.unwrap();
507    }
508
509    // --- answering the question that was asked ------------------------------
510
511    /// An otherwise well-formed, correctly correlated answer with one tuple
512    /// member rewritten.
513    fn answer_substituting(
514        request: &TrustTask<Value>,
515        field: &str,
516        value: &str,
517    ) -> TrustTask<Value> {
518        let mut reply = authorized_response(request, true);
519        reply.payload[field] = Value::String(value.to_string());
520        reply
521    }
522
523    #[tokio::test]
524    async fn answer_for_a_different_authority_is_rejected() {
525        let client = client_over(|req| {
526            Ok(answer_substituting(
527                &req,
528                "authority_id",
529                "did:example:other-authority",
530            ))
531        });
532        let err = client.authorization(query()).await.unwrap_err();
533        match err {
534            TrqlError::AnswerMismatch {
535                field,
536                ref asked,
537                ref answered,
538            } => {
539                assert_eq!(field, "authority_id");
540                assert_eq!(asked, "did:example:a");
541                assert_eq!(answered, "did:example:other-authority");
542            }
543            other => panic!("expected AnswerMismatch, got {other}"),
544        }
545        assert!(!err.is_retryable(), "a substituted answer is not transient");
546    }
547
548    #[tokio::test]
549    async fn answer_for_a_different_entity_is_rejected() {
550        let client = client_over(|req| {
551            Ok(answer_substituting(
552                &req,
553                "entity_id",
554                "did:example:someone-else",
555            ))
556        });
557        let err = client.authorization(query()).await.unwrap_err();
558        assert!(
559            matches!(
560                err,
561                TrqlError::AnswerMismatch {
562                    field: "entity_id",
563                    ..
564                }
565            ),
566            "got: {err}"
567        );
568    }
569
570    #[tokio::test]
571    async fn answer_for_a_different_action_or_resource_is_rejected() {
572        for (field, value) in [("action", "revoke"), ("resource", "some-other-repo")] {
573            let client = client_over(move |req| Ok(answer_substituting(&req, field, value)));
574            let err = client.authorization(query()).await.unwrap_err();
575            assert!(
576                matches!(err, TrqlError::AnswerMismatch { field: f, .. } if f == field),
577                "substituting {field} was not caught: {err}"
578            );
579        }
580    }
581
582    /// A denial is the answer an attacker would most like to see substituted
583    /// in the other direction, so the check must not be limited to grants.
584    #[tokio::test]
585    async fn a_denial_must_also_answer_the_question_asked() {
586        let client = client_over(|req| {
587            let mut reply = authorized_response(&req, false);
588            reply.payload["authority_id"] = Value::String("did:example:other".to_string());
589            Ok(reply)
590        });
591        let err = client.authorization(query()).await.unwrap_err();
592        assert!(
593            matches!(err, TrqlError::AnswerMismatch { .. }),
594            "got: {err}"
595        );
596    }
597
598    #[tokio::test]
599    async fn recognition_answers_are_checked_too() {
600        let client = client_over(|req| {
601            let payload = serde_json::json!({
602                "entity_id": req.payload["entity_id"],
603                "authority_id": "did:example:other",
604                "action": req.payload["action"],
605                "resource": req.payload["resource"],
606                "recognized": true,
607                "time_evaluated": "2026-07-16T00:00:00Z",
608            });
609            Ok(req.respond_with("urn:uuid:reply".to_string(), payload))
610        });
611        let err = client.recognition(query()).await.unwrap_err();
612        assert!(
613            matches!(err, TrqlError::AnswerMismatch { .. }),
614            "got: {err}"
615        );
616    }
617
618    /// An omitted tuple member is a malformed payload, not a substituted
619    /// answer. It still fails — the response schema requires all four — but it
620    /// is reported as the contract violation it is.
621    #[tokio::test]
622    async fn omitted_tuple_member_is_a_contract_error_not_a_mismatch() {
623        let client = client_over(|req| {
624            let mut reply = authorized_response(&req, true);
625            reply
626                .payload
627                .as_object_mut()
628                .expect("response payload is an object")
629                .remove("authority_id");
630            Ok(reply)
631        });
632        let err = client.authorization(query()).await.unwrap_err();
633        assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
634    }
635
636    // --- closing the referral loop (DID_SERVICE_DISCOVERY.md §4 step 5) -----
637
638    #[tokio::test]
639    async fn referral_closes_when_the_registry_answers_for_the_origin() {
640        let client =
641            client_over(|req| Ok(authorized_response(&req, true))).referred_by("did:example:a");
642        let response = client.authorization(query()).await.unwrap();
643        assert!(response.authorized);
644    }
645
646    /// The registry echoes our query faithfully — so the tuple check passes —
647    /// but we asked about an authority other than the one whose document
648    /// referred us here. The referral is left unconfirmed, and an unconfirmed
649    /// referral is an unverified redirect.
650    #[tokio::test]
651    async fn referral_that_the_answer_does_not_confirm_is_rejected() {
652        let client = client_over(|req| Ok(authorized_response(&req, true)))
653            .referred_by("did:example:referring-vtc");
654        let err = client.authorization(query()).await.unwrap_err();
655        match err {
656            TrqlError::ReferralNotClosed {
657                ref origin,
658                ref answered,
659            } => {
660                assert_eq!(origin, "did:example:referring-vtc");
661                assert_eq!(answered, "did:example:a");
662            }
663            other => panic!("expected ReferralNotClosed, got {other}"),
664        }
665        assert!(!err.is_retryable());
666    }
667
668    /// Without `referred_by` nothing changes: the endpoint path (a registry
669    /// describing its own surface) never had a referral to close.
670    #[tokio::test]
671    async fn endpoint_path_is_unaffected_by_the_referral_check() {
672        let client = client_over(|req| Ok(authorized_response(&req, true)));
673        assert!(client.authorization(query()).await.unwrap().authorized);
674    }
675}