vta_sdk/error.rs
1//! Structured error type for VTA SDK operations.
2
3/// Errors returned by VTA SDK client operations.
4#[derive(Debug, thiserror::Error)]
5pub enum VtaError {
6 /// Network-level error (connection refused, timeout, DNS failure).
7 #[cfg(feature = "client")]
8 #[error("network error: {0}")]
9 Network(#[from] reqwest::Error),
10
11 /// Authentication failed (401) or token expired.
12 #[error("authentication failed: {0}")]
13 Auth(String),
14
15 /// Resource not found (404).
16 #[error("not found: {0}")]
17 NotFound(String),
18
19 /// Request validation error (400).
20 #[error("validation error: {0}")]
21 Validation(String),
22
23 /// Permission denied (403).
24 #[error("forbidden: {0}")]
25 Forbidden(String),
26
27 /// Conflict (409) — e.g. duplicate key ID.
28 #[error("conflict: {0}")]
29 Conflict(String),
30
31 /// Gone (410) — the resource existed but is now permanently unavailable.
32 /// Most often emitted by the bootstrap carve-out endpoint after it has
33 /// been consumed; the CLI surfaces this with a "did you mean to run
34 /// `… provision-request`" hint instead of a flat string.
35 #[error("gone: {0}")]
36 Gone(String),
37
38 /// Server error (5xx).
39 #[error("server error ({status}): {body}")]
40 Server { status: u16, body: String },
41
42 /// The operation does not support the transport the client is
43 /// configured for (e.g. calling a REST-only helper on a client built
44 /// with DIDComm-only transport, or vice versa).
45 #[error("unsupported transport: {0}")]
46 UnsupportedTransport(String),
47
48 /// DIDComm transport failure (pack/send/pickup). Network-ish —
49 /// caller may want to retry. Distinct from [`Self::Network`] which
50 /// is REST-specific and carries a `reqwest::Error`.
51 #[error("didcomm transport error: {0}")]
52 DidcommTransport(String),
53
54 /// TSP transport failure (seal/route/websocket). Network-ish — caller may
55 /// want to retry. Kept distinct from [`Self::DidcommTransport`] rather than
56 /// folded into it: the two transports fail for different reasons and have
57 /// different recovery flags, and one shared message is what R6.4 exists to
58 /// prevent.
59 #[error("tsp transport error: {0}")]
60 TspTransport(String),
61
62 /// Remote endpoint returned a DIDComm problem-report whose `code`
63 /// did not match any of the standard `e.p.msg.*` taxonomy variants
64 /// (which map to the typed REST-aligned variants above). Inspect
65 /// `code` to handle it; a typed [`Self::Conflict`] / [`Self::NotFound`]
66 /// / [`Self::Auth`] / [`Self::Validation`] / [`Self::Server`] will
67 /// already have been emitted for the standard codes.
68 #[error("didcomm remote error ({code}): {comment}")]
69 DidcommRemote { code: String, comment: String },
70
71 /// Programmer-level protocol error (response shape did not match
72 /// what the SDK expected — version mismatch or bug). Distinct from
73 /// remote-error: a peer that returned a problem-report becomes a
74 /// typed variant via [`Self::from_problem_report`], not this one.
75 #[error("protocol error: {0}")]
76 Protocol(String),
77
78 /// The task needs a human approval that has not been given yet.
79 ///
80 /// Structured rather than folded into [`Self::Protocol`] because a caller
81 /// has to *act* on it: show the operator `payload_digest` so they can
82 /// compare it against the code on the approving device, then re-submit the
83 /// byte-identical request once approved. A flat string cannot carry that,
84 /// and the CLI's only option was to print the refusal and exit — which is
85 /// why a consent-gated task was unreachable from `pnm` entirely.
86 ///
87 /// The re-submit is safe to repeat *while the request is pending*: the
88 /// server returns the same `challenge` and deliberately does not re-notify
89 /// (the push follows the question, not the submit). It is NOT safe to
90 /// repeat blindly after a decision — a denial deletes the pending request,
91 /// so the next submit raises a new one and pushes again. Callers must stop
92 /// when `challenge` changes; see `vta_cli_common::consent`.
93 #[error(
94 "consent required: {min_approvals} approval(s) from `{approver_set}` — \
95 approve code {payload_digest} on an approving device"
96 )]
97 ConsentRequired {
98 /// The salted digest the approver signs and both screens compare.
99 payload_digest: String,
100 /// Nonce binding the decision to this request. Changes when the
101 /// request is resolved and a new one is raised.
102 challenge: String,
103 /// Named approver set the policy requires.
104 approver_set: String,
105 /// Distinct approvals needed.
106 min_approvals: u32,
107 /// Whether the requesting device is barred from counting toward the
108 /// threshold. `true` means this caller cannot self-approve however it
109 /// is enrolled, and must wait for another device; `false` means it may
110 /// approve its own request if it is a member of the set.
111 exclude_requester: bool,
112 },
113
114 /// Serialization/deserialization error.
115 #[error("serialization error: {0}")]
116 Serialization(#[from] serde_json::Error),
117
118 // ── Runtime service-management variants (spec §4) ──────────────
119 //
120 // These are emitted by the post-setup service-management surface
121 // (`services {rest,didcomm} {enable,update,disable,rollback}`).
122 // Structured data for the variants that carry numeric fields
123 // round-trips lossless via [`TypedErrorPayload`] across both
124 // REST response bodies and DIDComm problem-report args.
125 /// The operation would leave the VTA's DID document with no
126 /// advertised transport services. Per spec §3.2, this is rejected
127 /// without a `--force` escape hatch — enable the other transport
128 /// first if a swap is intended.
129 #[error("refusing operation: would leave the VTA with no advertised services")]
130 LastServiceRefused,
131
132 /// `update`, `disable`, or a kind-specific drain action was
133 /// invoked for a service kind that isn't currently enabled.
134 #[error("service is not present (not currently enabled)")]
135 ServiceNotPresent,
136
137 /// `enable` was invoked for a service kind that's already
138 /// enabled. Use `update` to change its configuration.
139 #[error("service is already enabled")]
140 ServiceAlreadyEnabled,
141
142 /// DIDComm handshake against the candidate mediator failed
143 /// (trust-ping refused, timed out, or peer was unreachable).
144 #[error("mediator handshake failed: {reason}")]
145 MediatorHandshakeFailed { reason: String },
146
147 /// Drain TTL is outside the valid range. Bounds are
148 /// `MIN_DRAIN_TTL_OVER_DIDCOMM` (3600s, when the disable command
149 /// is itself delivered over DIDComm) and `MAX_DRAIN_TTL`
150 /// (30 days). All three fields are in seconds.
151 #[error("drain ttl {requested}s outside allowed range [{min}s, {max}s]")]
152 DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
153
154 /// `rollback` was invoked for a service kind that has no prior
155 /// mutation in its snapshot store to fail-forward from.
156 #[error("no prior mutation to roll back from")]
157 NoPriorMutation,
158
159 /// Catch-all for other errors.
160 /// No transport protocol is advertised by **both** this party and the
161 /// counterparty, so there is no way to communicate. Carries each side's
162 /// advertised set (in preference order) so the CLI can show the operator
163 /// what each offers and which transport to enable. Determined locally by
164 /// [`crate::protocol::matching::select_protocol`] after resolving the
165 /// peer's DID document — never a server-returned wire error.
166 #[error(
167 "no transport protocol in common with {counterparty_did}: \
168 we advertise {ours:?}, they advertise {theirs:?}"
169 )]
170 NoMatchingProtocol {
171 counterparty_did: String,
172 ours: Vec<crate::protocol::matching::Protocol>,
173 theirs: Vec<crate::protocol::matching::Protocol>,
174 },
175
176 #[error("{0}")]
177 Other(String),
178}
179
180/// Wire-format companion to the typed [`VtaError`] variants emitted
181/// by the runtime service-management surface.
182///
183/// The free-form `comment` string carried by DIDComm problem-reports
184/// (and the `body` string of REST error responses) is fine for the
185/// variants whose only data is a human-readable message
186/// ([`VtaError::Conflict`], [`VtaError::NotFound`], …) but lossy for
187/// variants like [`VtaError::DrainTtlOutOfBounds`] that carry three
188/// numeric fields the CLI needs to switch on.
189///
190/// Servers serialize a `TypedErrorPayload` into the response body
191/// (REST) or problem-report `args` (DIDComm); clients deserialize
192/// it back via [`VtaError::from_typed_payload`]. The discriminator
193/// is the kebab-cased variant name in the `code` field.
194///
195/// Variants line up 1:1 with the §4 spec list — the existing
196/// [`VtaError::UnsupportedTransport`] is included so the same
197/// channel carries every typed-error wire form.
198#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
199#[serde(tag = "code", rename_all = "kebab-case")]
200pub enum TypedErrorPayload {
201 LastServiceRefused,
202 ServiceNotPresent,
203 ServiceAlreadyEnabled,
204 MediatorHandshakeFailed { reason: String },
205 DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
206 NoPriorMutation,
207 UnsupportedTransport { detail: String },
208}
209
210impl VtaError {
211 /// Create from an HTTP response status and error body.
212 ///
213 /// Public so a downstream SDK consumer wiring its own HTTP transport
214 /// (e.g. a wasm `gloo-net` client) can produce typed `VtaError`s
215 /// from status codes without re-implementing the mapping.
216 #[cfg(feature = "client")]
217 pub fn from_http(status: reqwest::StatusCode, body: String) -> Self {
218 match status.as_u16() {
219 401 => Self::Auth(body),
220 403 => Self::Forbidden(body),
221 404 => Self::NotFound(body),
222 400 | 422 => Self::Validation(body),
223 409 => Self::Conflict(body),
224 410 => Self::Gone(body),
225 s if s >= 500 => Self::Server { status: s, body },
226 s => Self::Other(format!("{s}: {body}")),
227 }
228 }
229
230 /// Create from a DIDComm problem-report `code` + `comment`. Mirrors
231 /// the REST [`Self::from_http`] mapping so callers can `match` on the
232 /// same variants regardless of transport.
233 ///
234 /// Standard codes (`e.p.msg.unauthorized` / `bad-request` / `not-found`
235 /// / `conflict` / `internal-error`) become typed variants. Anything
236 /// else lands in [`Self::DidcommRemote`] preserving the original code.
237 pub fn from_problem_report(code: &str, comment: impl Into<String>) -> Self {
238 use crate::protocols::problem_report_codes as c;
239 let comment = comment.into();
240 match code {
241 c::CONFLICT => Self::Conflict(comment),
242 c::NOT_FOUND => Self::NotFound(comment),
243 c::UNAUTHORIZED => Self::Auth(comment),
244 c::FORBIDDEN => Self::Forbidden(comment),
245 c::BAD_REQUEST => Self::Validation(comment),
246 c::INTERNAL => Self::Server {
247 status: 500,
248 body: comment,
249 },
250 other => Self::DidcommRemote {
251 code: other.to_string(),
252 comment,
253 },
254 }
255 }
256
257 /// Reconstruct the typed [`VtaError`] variant from a wire-format
258 /// [`TypedErrorPayload`]. Used by the client when decoding REST
259 /// response bodies / DIDComm problem-report args for the runtime
260 /// service-management surface (spec §4).
261 pub fn from_typed_payload(payload: TypedErrorPayload) -> Self {
262 match payload {
263 TypedErrorPayload::LastServiceRefused => Self::LastServiceRefused,
264 TypedErrorPayload::ServiceNotPresent => Self::ServiceNotPresent,
265 TypedErrorPayload::ServiceAlreadyEnabled => Self::ServiceAlreadyEnabled,
266 TypedErrorPayload::MediatorHandshakeFailed { reason } => {
267 Self::MediatorHandshakeFailed { reason }
268 }
269 TypedErrorPayload::DrainTtlOutOfBounds {
270 min,
271 max,
272 requested,
273 } => Self::DrainTtlOutOfBounds {
274 min,
275 max,
276 requested,
277 },
278 TypedErrorPayload::NoPriorMutation => Self::NoPriorMutation,
279 TypedErrorPayload::UnsupportedTransport { detail } => {
280 Self::UnsupportedTransport(detail)
281 }
282 }
283 }
284
285 /// Project this error onto the wire-format [`TypedErrorPayload`]
286 /// when the variant is one of the runtime service-management
287 /// errors. Returns `None` for variants that don't have a
288 /// structured wire form (network errors, generic conflicts,
289 /// programmer-level protocol errors, …).
290 #[must_use]
291 pub fn to_typed_payload(&self) -> Option<TypedErrorPayload> {
292 match self {
293 Self::LastServiceRefused => Some(TypedErrorPayload::LastServiceRefused),
294 Self::ServiceNotPresent => Some(TypedErrorPayload::ServiceNotPresent),
295 Self::ServiceAlreadyEnabled => Some(TypedErrorPayload::ServiceAlreadyEnabled),
296 Self::MediatorHandshakeFailed { reason } => {
297 Some(TypedErrorPayload::MediatorHandshakeFailed {
298 reason: reason.clone(),
299 })
300 }
301 Self::DrainTtlOutOfBounds {
302 min,
303 max,
304 requested,
305 } => Some(TypedErrorPayload::DrainTtlOutOfBounds {
306 min: *min,
307 max: *max,
308 requested: *requested,
309 }),
310 Self::NoPriorMutation => Some(TypedErrorPayload::NoPriorMutation),
311 Self::UnsupportedTransport(detail) => Some(TypedErrorPayload::UnsupportedTransport {
312 detail: detail.clone(),
313 }),
314 _ => None,
315 }
316 }
317
318 /// Returns true if the resource was permanently consumed/gone (410).
319 pub fn is_gone(&self) -> bool {
320 matches!(self, Self::Gone(_))
321 }
322
323 /// Returns true if a create/insert collided with an existing entry (409).
324 pub fn is_conflict(&self) -> bool {
325 matches!(self, Self::Conflict(_))
326 }
327
328 /// Returns true if this is an authentication/authorization error.
329 pub fn is_auth(&self) -> bool {
330 matches!(self, Self::Auth(_) | Self::Forbidden(_))
331 }
332
333 /// Returns true if this is a network-level error (retryable).
334 pub fn is_network(&self) -> bool {
335 #[cfg(feature = "client")]
336 if matches!(self, Self::Network(_)) {
337 return true;
338 }
339 false
340 }
341
342 /// Returns true if the resource was not found.
343 pub fn is_not_found(&self) -> bool {
344 matches!(self, Self::NotFound(_))
345 }
346
347 /// Operator-actionable hint matching this error variant.
348 ///
349 /// `None` for variants where no generic guidance applies (the message
350 /// itself is the hint, or the failure is a programmer error). The
351 /// CLI layer (`vta-cli-common::render::print_cli_error`) already
352 /// implements bin-aware suggestions ("`pnm acl create …`"); this
353 /// method gives **non-CLI consumers** — web UIs, GUIs, custom
354 /// dashboards — the same hint surface without needing to fork the
355 /// dispatch logic.
356 ///
357 /// Returns a `&'static str` so callers can compose it into their
358 /// own UI without lifetime juggling. The bin-specific substitution
359 /// (`pnm` vs `cnm`) is left to the CLI layer because only it
360 /// knows which binary the operator is running.
361 #[must_use]
362 pub fn suggested_fix(&self) -> Option<&'static str> {
363 match self {
364 Self::Auth(_) => Some(
365 "Token may be expired. Re-authenticate against the VTA, or check that \
366 the `/auth` endpoint is reachable.",
367 ),
368 Self::Forbidden(_) => Some(
369 "Your role or context access doesn't permit this operation. Inspect \
370 the ACL entry for your DID against the target context.",
371 ),
372 Self::Gone(_) => Some(
373 "The resource has been permanently consumed. The single-use bootstrap \
374 carve-out has likely already been used; ask an existing admin to \
375 provision-integration a new operator instead.",
376 ),
377 Self::Conflict(_) => Some(
378 "The resource already exists. Use the corresponding `update` or \
379 `delete-then-create` flow rather than `create`.",
380 ),
381 Self::Validation(_) => Some(
382 "The request body or parameters were rejected by the VTA's schema. \
383 Inspect the response body for the specific field that failed.",
384 ),
385 Self::Server { .. } => {
386 Some("VTA-side failure. Check the VTA's server logs or contact the operator.")
387 }
388 Self::UnsupportedTransport(_) => Some(
389 "The operation requires a specific transport (REST or DIDComm). \
390 Check which mode the client is in and whether the endpoint supports it.",
391 ),
392 Self::DidcommTransport(_) => {
393 Some("Mediator or peer unreachable. Retry after checking mediator connectivity.")
394 }
395 Self::TspTransport(_) => Some(
396 "The VTA's TSP mediator is unreachable or rejected the frame. Retry, or \
397 reach the VTA over another transport: `--transport didcomm` / \
398 `--transport rest`.",
399 ),
400 #[cfg(feature = "client")]
401 Self::Network(_) => Some(
402 "Network error reaching the VTA. Confirm the URL is correct and the \
403 host is reachable.",
404 ),
405 // Runtime service-management variants (spec §4). The CLI
406 // layer enriches these with the specific kind/command
407 // it just ran; this is the generic fallback hint for
408 // non-CLI consumers.
409 Self::LastServiceRefused => Some(
410 "This operation would leave the VTA with no advertised transport \
411 services. Enable the other transport first (REST or DIDComm) \
412 before disabling this one.",
413 ),
414 Self::ServiceNotPresent => Some(
415 "The service kind isn't currently enabled. Use \
416 `services <kind> enable …` to bring it online before \
417 updating, disabling, or rolling it back.",
418 ),
419 Self::ServiceAlreadyEnabled => Some(
420 "The service kind is already enabled. Use \
421 `services <kind> update …` to change its configuration, \
422 or `disable` to remove it.",
423 ),
424 Self::MediatorHandshakeFailed { .. } => Some(
425 "DIDComm handshake against the candidate mediator failed. \
426 Confirm the mediator DID is correct and the mediator is \
427 reachable; check the inner reason for the specific cause.",
428 ),
429 Self::DrainTtlOutOfBounds { .. } => Some(
430 "The supplied drain TTL is outside the allowed range. Pick a \
431 value within the [min, max] interval shown in the error message.",
432 ),
433 Self::NoPriorMutation => Some(
434 "No prior mutation for this service kind to roll back from. Use \
435 the direct `enable`/`update`/`disable` command instead.",
436 ),
437 Self::NoMatchingProtocol { .. } => Some(
438 "The two parties share no transport protocol. Enable a common \
439 transport (TSP, DIDComm, or REST) on both sides — compare each \
440 DID document's advertised `service` entries and add the missing one.",
441 ),
442 // No generic hint for these — the message itself is the
443 // hint, or the failure is a protocol/programmer error
444 // surface that an automated suggestion would only confuse.
445 // The hint depends on policy the message already reports — whether
446 // another device must approve, or this one may. A static string
447 // would have to guess, and guessing wrong sends the operator to the
448 // wrong screen. The CLI's consent loop says it precisely instead.
449 Self::ConsentRequired { .. } => None,
450 Self::NotFound(_)
451 | Self::DidcommRemote { .. }
452 | Self::Protocol(_)
453 | Self::Serialization(_)
454 | Self::Other(_) => None,
455 }
456 }
457}
458
459impl From<crate::did_key::DidKeyError> for VtaError {
460 fn from(e: crate::did_key::DidKeyError) -> Self {
461 Self::Validation(e.to_string())
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[cfg(feature = "client")]
470 #[test]
471 fn from_http_410_maps_to_gone() {
472 let err = VtaError::from_http(reqwest::StatusCode::GONE, "carve-out closed".into());
473 assert!(err.is_gone(), "410 must map to VtaError::Gone, got {err:?}");
474 }
475
476 #[test]
477 fn problem_report_conflict_maps_to_typed_conflict() {
478 let err = VtaError::from_problem_report(
479 crate::protocols::problem_report_codes::CONFLICT,
480 "key id already exists",
481 );
482 assert!(matches!(err, VtaError::Conflict(_)), "got {err:?}");
483 assert!(err.is_conflict());
484 }
485
486 #[test]
487 fn problem_report_unknown_code_lands_in_didcomm_remote() {
488 let err = VtaError::from_problem_report("e.custom.xyz", "weird thing");
489 match err {
490 VtaError::DidcommRemote { code, comment } => {
491 assert_eq!(code, "e.custom.xyz");
492 assert_eq!(comment, "weird thing");
493 }
494 other => panic!("expected DidcommRemote, got {other:?}"),
495 }
496 }
497
498 #[test]
499 fn suggested_fix_present_for_actionable_variants() {
500 // Each "operator can do something about this" variant must have
501 // a hint string; the message-is-the-hint / programmer-error
502 // variants return None.
503 assert!(VtaError::Auth("expired".into()).suggested_fix().is_some());
504 assert!(VtaError::Forbidden("nope".into()).suggested_fix().is_some());
505 assert!(VtaError::Gone("used".into()).suggested_fix().is_some());
506 assert!(VtaError::Conflict("dup".into()).suggested_fix().is_some());
507 assert!(VtaError::Validation("bad".into()).suggested_fix().is_some());
508 assert!(
509 VtaError::Server {
510 status: 500,
511 body: "boom".into(),
512 }
513 .suggested_fix()
514 .is_some()
515 );
516 assert!(
517 VtaError::UnsupportedTransport("rest only".into())
518 .suggested_fix()
519 .is_some()
520 );
521 assert!(
522 VtaError::DidcommTransport("offline".into())
523 .suggested_fix()
524 .is_some()
525 );
526
527 // Runtime service-management variants (spec §4) all have hints.
528 assert!(VtaError::LastServiceRefused.suggested_fix().is_some());
529 assert!(VtaError::ServiceNotPresent.suggested_fix().is_some());
530 assert!(VtaError::ServiceAlreadyEnabled.suggested_fix().is_some());
531 assert!(
532 VtaError::MediatorHandshakeFailed {
533 reason: "trust-ping timeout".into()
534 }
535 .suggested_fix()
536 .is_some()
537 );
538 assert!(
539 VtaError::DrainTtlOutOfBounds {
540 min: 3600,
541 max: 2_592_000,
542 requested: 30,
543 }
544 .suggested_fix()
545 .is_some()
546 );
547 assert!(VtaError::NoPriorMutation.suggested_fix().is_some());
548
549 // Self-explanatory / programmer-error: no canned hint.
550 assert!(VtaError::NotFound("x".into()).suggested_fix().is_none());
551 assert!(VtaError::Protocol("shape".into()).suggested_fix().is_none());
552 assert!(
553 VtaError::DidcommRemote {
554 code: "e.unknown".into(),
555 comment: "x".into()
556 }
557 .suggested_fix()
558 .is_none()
559 );
560 }
561
562 /// Every typed runtime service-management variant must round-trip
563 /// through [`TypedErrorPayload`] without losing structured data.
564 /// The test cases line up 1:1 with the spec §4 list.
565 #[test]
566 fn typed_payload_round_trips_every_runtime_service_variant() {
567 let cases: Vec<VtaError> = vec![
568 VtaError::LastServiceRefused,
569 VtaError::ServiceNotPresent,
570 VtaError::ServiceAlreadyEnabled,
571 VtaError::MediatorHandshakeFailed {
572 reason: "trust-ping timeout after 10s".into(),
573 },
574 VtaError::DrainTtlOutOfBounds {
575 min: 3600,
576 max: 2_592_000,
577 requested: 30,
578 },
579 VtaError::NoPriorMutation,
580 VtaError::UnsupportedTransport("services didcomm enable is REST-only".into()),
581 ];
582
583 for original in cases {
584 let payload = original.to_typed_payload().unwrap_or_else(|| {
585 panic!("variant must project to TypedErrorPayload: {original:?}")
586 });
587
588 // Round-trip through JSON to mirror what REST and DIDComm
589 // transports actually do on the wire.
590 let json = serde_json::to_string(&payload)
591 .unwrap_or_else(|e| panic!("payload must serialize: {e}"));
592 let restored: TypedErrorPayload = serde_json::from_str(&json)
593 .unwrap_or_else(|e| panic!("payload must deserialize: {e}; raw={json}"));
594
595 assert_eq!(
596 payload, restored,
597 "TypedErrorPayload must round-trip through JSON",
598 );
599
600 // Reconstructing back to VtaError preserves the variant
601 // discriminant and any structured data.
602 let reconstructed = VtaError::from_typed_payload(restored);
603 match (&original, &reconstructed) {
604 (VtaError::LastServiceRefused, VtaError::LastServiceRefused)
605 | (VtaError::ServiceNotPresent, VtaError::ServiceNotPresent)
606 | (VtaError::ServiceAlreadyEnabled, VtaError::ServiceAlreadyEnabled)
607 | (VtaError::NoPriorMutation, VtaError::NoPriorMutation) => {}
608 (
609 VtaError::MediatorHandshakeFailed { reason: a },
610 VtaError::MediatorHandshakeFailed { reason: b },
611 ) => assert_eq!(a, b),
612 (
613 VtaError::DrainTtlOutOfBounds {
614 min: m1,
615 max: x1,
616 requested: r1,
617 },
618 VtaError::DrainTtlOutOfBounds {
619 min: m2,
620 max: x2,
621 requested: r2,
622 },
623 ) => {
624 assert_eq!(m1, m2);
625 assert_eq!(x1, x2);
626 assert_eq!(r1, r2);
627 }
628 (VtaError::UnsupportedTransport(a), VtaError::UnsupportedTransport(b)) => {
629 assert_eq!(a, b)
630 }
631 (a, b) => panic!("variant changed across round-trip: {a:?} → {b:?}"),
632 }
633 }
634 }
635
636 /// The kebab-case `code` discriminator on the wire JSON is part of
637 /// the contract for both REST and DIDComm transports — pin it
638 /// explicitly so a `serde(rename)` change doesn't silently break
639 /// existing peers.
640 #[test]
641 fn typed_payload_wire_discriminator_is_kebab_case() {
642 let payload = TypedErrorPayload::DrainTtlOutOfBounds {
643 min: 3600,
644 max: 2_592_000,
645 requested: 30,
646 };
647 let json = serde_json::to_value(&payload).unwrap();
648 assert_eq!(json["code"], "drain-ttl-out-of-bounds");
649 assert_eq!(json["min"], 3600);
650 assert_eq!(json["max"], 2_592_000);
651 assert_eq!(json["requested"], 30);
652 }
653
654 /// `to_typed_payload` returns `None` for variants outside the
655 /// runtime service-management surface — the wire-format channel
656 /// is reserved for those typed variants and shouldn't blanket
657 /// every error.
658 #[test]
659 fn typed_payload_is_none_for_non_service_management_variants() {
660 assert!(VtaError::Auth("x".into()).to_typed_payload().is_none());
661 assert!(VtaError::NotFound("x".into()).to_typed_payload().is_none());
662 assert!(VtaError::Conflict("x".into()).to_typed_payload().is_none());
663 assert!(
664 VtaError::Server {
665 status: 500,
666 body: "x".into(),
667 }
668 .to_typed_payload()
669 .is_none()
670 );
671 assert!(VtaError::Protocol("x".into()).to_typed_payload().is_none());
672 assert!(
673 VtaError::DidcommRemote {
674 code: "e.x".into(),
675 comment: "x".into()
676 }
677 .to_typed_payload()
678 .is_none()
679 );
680 assert!(VtaError::Other("x".into()).to_typed_payload().is_none());
681 }
682}