use std::sync::Arc;
use std::time::Duration;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use affinidi_messaging_core::{Inbound, MessageTransport};
use affinidi_messaging_delivery::{Delivery, MessagingService, OutboxStore};
use affinidi_messaging_didcomm::Message;
use affinidi_tdk::common::TDKSharedState;
use affinidi_tdk::common::config::TDKConfig;
use affinidi_tdk::messaging::config::ATMConfig;
use affinidi_tdk::messaging::profiles::ATMProfile;
use affinidi_tdk::messaging::{ATM, DidCommTransport};
use affinidi_tdk::secrets_resolver::secrets::Secret;
use affinidi_tdk::secrets_resolver::{SecretsResolver, ThreadedSecretsResolver};
use futures_util::StreamExt;
use tokio::sync::watch;
use tracing::{info, warn};
use serde_json::json;
use vti_common::error::AppError;
use vti_common::outbox_store::VtiOutboxStore;
use vta_sdk::protocols::credential_exchange::PRESENT as CREDENTIAL_PRESENT_TYPE;
use vta_sdk::protocols::credential_exchange::REQUEST as CREDENTIAL_REQUEST_TYPE;
use vta_sdk::protocols::credential_exchange::{
ISSUE as CREDENTIAL_ISSUE_TYPE, IssueBody, PresentBody, RequestBody,
};
use vta_sdk::protocols::join_requests::{
JOIN_REQUEST_ACCEPT_TYPE, JOIN_REQUEST_MANIFEST_TYPE, JOIN_REQUEST_STATUS_TYPE,
JOIN_REQUEST_SUBMIT_RECEIPT_TYPE, JOIN_REQUEST_SUBMIT_TYPE, JoinRequestSubmitReceiptBody,
MEMBER_SELF_REMOVE_RECEIPT_TYPE, MEMBER_SELF_REMOVE_TYPE, SelfRemoveBody,
SelfRemoveReceiptBody,
};
use vta_sdk::protocols::members::{
MEMBER_VMC_RESPONSE_TYPE, MEMBER_VMC_TYPE, MemberVmcBody, MemberVmcReceiptBody,
};
use vta_sdk::protocols::{PROBLEM_REPORT_TYPE, problem_report_codes as codes};
use crate::ceremony::remove_inner;
use crate::config::AppConfig;
use crate::join::JoinTransport;
use crate::members::Disposition;
use crate::server::AppState;
use crate::store::KeyspaceHandle;
use crate::trust_tasks::{JoinAuthCtx, TrustTaskOutcome, dispatch_trust_task_core};
const TRUST_PING_TYPE: &str = "https://didcomm.org/trust-ping/2.0/ping";
const TRUST_PONG_TYPE: &str = "https://didcomm.org/trust-ping/2.0/ping-response";
const MESSAGE_PICKUP_STATUS_TYPE: &str = "https://didcomm.org/messagepickup/3.0/status";
pub struct VtcMessaging {
pub service: Arc<MessagingService>,
pub atm: Arc<ATM>,
pub vtc_did: String,
}
async fn vtc_key_ids(
did_resolver: Option<&DIDCacheClient>,
vtc_did: &str,
) -> (String, Option<String>) {
let conventional = || (format!("{vtc_did}#key-0"), Some(format!("{vtc_did}#key-1")));
let Some(resolver) = did_resolver else {
return conventional();
};
let resolved = match resolver.resolve(vtc_did).await {
Ok(r) => r,
Err(e) => {
warn!(
%vtc_did,
error = %e,
"could not resolve the VTC DID for its key ids — falling back to #key-0/#key-1",
);
return conventional();
}
};
let absolutize = |id: &str| -> String {
match id.strip_prefix('#') {
Some(fragment) => format!("{vtc_did}#{fragment}"),
None => id.to_string(),
}
};
let doc = &resolved.doc;
let signing = doc
.authentication
.first()
.map(|vr| vr.get_id())
.or_else(|| doc.verification_method.first().map(|vm| vm.id.as_str()))
.map(&absolutize);
let ka = doc.key_agreement.first().map(|vr| absolutize(vr.get_id()));
match signing {
Some(signing) => (signing, ka),
None => conventional(),
}
}
async fn build_messaging(
secrets: Vec<Secret>,
vtc_did: &str,
mediator_did: &str,
outbox_ks: KeyspaceHandle,
) -> Result<(Arc<MessagingService>, Arc<ATM>), String> {
let tdk = TDKSharedState::new(
TDKConfig::builder()
.build()
.map_err(|e| format!("build TDK config: {e}"))?,
)
.await
.map_err(|e| format!("create TDK shared state: {e}"))?;
for secret in secrets {
tdk.secrets_resolver().insert(secret).await;
}
let atm = ATM::new(
ATMConfig::builder()
.build()
.map_err(|e| format!("build ATM config: {e}"))?,
Arc::new(tdk),
)
.await
.map_err(|e| format!("create ATM: {e}"))?;
let profile = Arc::new(
ATMProfile::new(
&atm,
None,
vtc_did.to_string(),
Some(mediator_did.to_string()),
)
.await
.map_err(|e| format!("create ATM profile: {e}"))?,
);
match tokio::time::timeout(
Duration::from_secs(30),
atm.profile_enable_websocket(&profile),
)
.await
{
Ok(res) => res.map_err(|e| format!("enable websocket: {e}"))?,
Err(_) => {
return Err(
"timeout enabling websocket to mediator after 30s — mediator may be unreachable"
.to_string(),
);
}
}
let atm = Arc::new(atm);
let transport: Arc<dyn MessageTransport> = Arc::new(
DidCommTransport::new((*atm).clone(), profile.clone())
.await
.map_err(|e| format!("bind DidComm transport: {e}"))?,
);
let outbox: Arc<dyn OutboxStore> = Arc::new(VtiOutboxStore::new(outbox_ks));
let service = Arc::new(MessagingService::new(transport, outbox));
Ok((service, atm))
}
pub async fn run_didcomm_service(
config: &AppConfig,
secrets_resolver: &Arc<ThreadedSecretsResolver>,
vtc_did: &str,
state: AppState,
shutdown_rx: &mut watch::Receiver<bool>,
) {
let mediator_did = match &config.messaging {
Some(m) => m.mediator_did.clone(),
None => {
warn!("messaging not configured — inbound message handling disabled");
let _ = shutdown_rx.changed().await;
return;
}
};
let (signing_id, ka_id) = vtc_key_ids(state.did_resolver.as_ref(), vtc_did).await;
let mut secrets = Vec::new();
if let Some(s) = secrets_resolver.get_secret(&signing_id).await {
secrets.push(s);
} else {
warn!(%signing_id, "VTC signing secret not found — messaging disabled");
let _ = shutdown_rx.changed().await;
return;
}
if let Some(ka_id) = ka_id.as_deref()
&& let Some(s) = secrets_resolver.get_secret(ka_id).await
{
secrets.push(s);
}
info!(
vtc_did = %vtc_did,
mediator = %mediator_did,
"starting VTC messaging listener"
);
let (service, atm) =
match build_messaging(secrets, vtc_did, &mediator_did, state.outbox_ks.clone()).await {
Ok(v) => v,
Err(e) => {
warn!("failed to start VTC messaging: {e}");
let _ = shutdown_rx.changed().await;
return;
}
};
let messaging = Arc::new(VtcMessaging {
service: service.clone(),
atm: atm.clone(),
vtc_did: vtc_did.to_string(),
});
if state.didcomm.set(messaging).is_err() {
warn!("VTC messaging handle was already published — outbound sends use the existing one");
}
info!("VTC messaging connected to mediator — inbound messages will be processed");
let vtc_did_owned = vtc_did.to_string();
let mut stream = service.subscribe();
loop {
tokio::select! {
maybe = stream.next() => {
let Some(inbound) = maybe else {
warn!("VTC inbound stream ended — messaging dispatcher stopping");
break;
};
let reply_to = inbound.message.sender.clone().or_else(|| {
serde_json::from_slice::<Message>(&inbound.message.payload)
.ok()
.and_then(|m| m.from)
});
if let Some(reply) = dispatch(inbound, &state).await {
let Some(to) = reply_to else {
warn!(
reply_type = %reply.type_,
"computed a DIDComm reply but the inbound message had no sender/from \
to reply to — dropping"
);
continue;
};
let reply_id = uuid::Uuid::new_v4().to_string();
let reply_msg = Message::build(reply_id, reply.type_, reply.body)
.from(vtc_did_owned.clone())
.to(to.clone())
.thid(reply.thid)
.finalize();
match atm
.pack_encrypted(&reply_msg, &to, Some(&vtc_did_owned), Some(&vtc_did_owned))
.await
{
Ok((packed, _)) => {
if let Err(e) = service
.send(&to, packed.into_bytes(), Delivery::BestEffort)
.await
{
warn!(recipient = %to, error = %e, "failed to send DIDComm reply");
}
}
Err(e) => warn!(recipient = %to, error = %e, "failed to pack DIDComm reply"),
}
}
}
_ = shutdown_rx.changed() => {
info!("VTC messaging stopping (shutdown signalled)");
break;
}
}
}
info!("VTC messaging stopped");
}
struct Reply {
type_: String,
body: serde_json::Value,
thid: String,
}
async fn dispatch(inbound: Inbound, state: &AppState) -> Option<Reply> {
let msg: Message = serde_json::from_slice(&inbound.message.payload).ok()?;
if msg.typ == MESSAGE_PICKUP_STATUS_TYPE {
return None;
}
let auth_sender = inbound
.message
.sender
.clone()
.filter(|_| inbound.message.verified);
let start = std::time::Instant::now();
let message_type = msg.typ.clone();
let sender_log = auth_sender
.clone()
.or_else(|| msg.from.clone())
.unwrap_or_else(|| "<anon>".to_string());
let reply = route(&msg, auth_sender, state).await;
info!(
target: "didcomm_server::request",
message_type = %message_type,
sender = %sender_log,
status = if reply.is_some() { "ok(response)" } else { "ok(empty)" },
latency = ?start.elapsed(),
"Request processed"
);
reply
}
async fn route(msg: &Message, auth_sender: Option<String>, state: &AppState) -> Option<Reply> {
match msg.typ.as_str() {
TRUST_PING_TYPE => trust_ping_reply(msg, auth_sender.as_deref()),
JOIN_REQUEST_SUBMIT_TYPE => join_request_submit_handler(msg, auth_sender, state).await,
JOIN_REQUEST_ACCEPT_TYPE => join_request_accept_handler(msg, auth_sender, state).await,
JOIN_REQUEST_MANIFEST_TYPE => join_request_manifest_handler(msg, state).await,
JOIN_REQUEST_STATUS_TYPE => join_request_status_handler(msg, auth_sender, state).await,
MEMBER_SELF_REMOVE_TYPE => member_self_remove_handler(msg, auth_sender, state).await,
MEMBER_VMC_TYPE => member_vmc_handler(msg, auth_sender, state).await,
CREDENTIAL_REQUEST_TYPE => credential_request_handler(msg, state).await,
CREDENTIAL_PRESENT_TYPE => credential_present_handler(msg, state).await,
_ => unhandled_message(msg),
}
}
fn trust_ping_reply(msg: &Message, auth_sender: Option<&str>) -> Option<Reply> {
#[derive(serde::Deserialize)]
struct PingBody {
#[serde(default = "default_true")]
response_requested: bool,
}
fn default_true() -> bool {
true
}
let body: PingBody = serde_json::from_value(msg.body.clone()).unwrap_or(PingBody {
response_requested: true,
});
if !body.response_requested {
return None;
}
auth_sender?;
Some(Reply {
type_: TRUST_PONG_TYPE.to_string(),
body: serde_json::Value::Null,
thid: msg.id.clone(),
})
}
fn problem_report(thid: String, code: &str, comment: impl Into<String>) -> Reply {
Reply {
type_: PROBLEM_REPORT_TYPE.to_string(),
body: problem_report_body(code, comment),
thid,
}
}
fn problem_report_body(code: &str, comment: impl Into<String>) -> serde_json::Value {
json!({ "code": code, "comment": comment.into() })
}
fn problem_report_details(body: &serde_json::Value) -> (String, String, String) {
let field = |k: &str| {
body.get(k)
.and_then(|v| v.as_str())
.unwrap_or("<none>")
.to_string()
};
let args = match body.get("args").and_then(|v| v.as_array()) {
Some(a) if !a.is_empty() => a
.iter()
.map(|v| {
v.as_str()
.map(str::to_string)
.unwrap_or_else(|| v.to_string())
})
.collect::<Vec<_>>()
.join(", "),
_ => "<none>".to_string(),
};
(field("code"), field("comment"), args)
}
pub(crate) fn app_error_code(err: &AppError) -> &'static str {
match err {
AppError::Forbidden(_) | AppError::StepUpRequired(_) => codes::FORBIDDEN,
AppError::Unauthorized(_) | AppError::Authentication(_) => codes::UNAUTHORIZED,
AppError::NotFound(_) => codes::NOT_FOUND,
AppError::Conflict(_) | AppError::IdempotencyKeyConflict => codes::CONFLICT,
AppError::Validation(_)
| AppError::TrustTaskMalformed(_)
| AppError::TrustTaskMissing
| AppError::InvalidCursor => codes::BAD_REQUEST,
_ => codes::INTERNAL,
}
}
fn app_error_report(thid: String, err: &AppError) -> Reply {
problem_report(thid, app_error_code(err), err.to_string())
}
fn unhandled_message(message: &Message) -> Option<Reply> {
if message.typ.contains("problem-report") {
let (code, comment, args) = problem_report_details(&message.body);
warn!(
from = message.from.as_deref().unwrap_or("<anon>"),
thid = message.thid.as_deref().unwrap_or("<none>"),
code = %code,
comment = %comment,
args = %args,
body = %message.body,
id = %message.id,
"received unhandled problem-report — not replying (a reply would loop)"
);
return None;
}
warn!(
message_type = %message.typ,
from = message.from.as_deref().unwrap_or("<anon>"),
id = %message.id,
"inbound DIDComm message has no matching handler — dropping (unsupported message type)"
);
Some(problem_report(
message.id.clone(),
codes::BAD_REQUEST,
format!("unsupported message type: {}", message.typ),
))
}
fn tt_didcomm_reply(outcome: TrustTaskOutcome, thid: String) -> Option<Reply> {
let doc: serde_json::Value = match serde_json::from_slice(&outcome.body) {
Ok(d) => d,
Err(e) => {
return Some(problem_report(
thid,
codes::INTERNAL,
format!("reply document parse: {e}"),
));
}
};
let typ = doc
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("https://trusttasks.org/spec/trust-task-error/0.1")
.to_string();
Some(Reply {
type_: typ,
body: doc,
thid,
})
}
fn inbound_doc_bytes(message: &Message) -> Result<Vec<u8>, String> {
serde_json::to_vec(&message.body).map_err(|e| format!("serialise inbound document: {e}"))
}
fn error_doc_summary(body: &[u8]) -> (String, Option<String>) {
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(body) else {
return ("<unparseable error document>".to_string(), None);
};
let code = doc
.pointer("/payload/code")
.and_then(|c| c.as_str())
.unwrap_or("<unknown>")
.to_string();
let message = doc
.pointer("/payload/message")
.and_then(|m| m.as_str())
.map(str::to_string);
(code, message)
}
fn unauthorized_reply(thid: String) -> Reply {
problem_report(
thid,
codes::UNAUTHORIZED,
"DIDComm message is not authcrypt-authenticated — sender cannot be trusted",
)
}
async fn join_request_submit_handler(
msg: &Message,
auth_sender: Option<String>,
state: &AppState,
) -> Option<Reply> {
let thid = msg.id.clone();
let Some(applicant_did) = auth_sender else {
return Some(unauthorized_reply(thid));
};
let applicant_log = applicant_did.clone();
info!(
applicant = %applicant_did,
thid = %thid,
has_credential = msg
.body
.pointer("/payload/vp/verifiableCredential")
.is_some(),
"received join-request submit (Trust Task) over DIDComm"
);
let body = match inbound_doc_bytes(msg) {
Ok(b) => b,
Err(e) => return Some(problem_report(thid, codes::INTERNAL, e)),
};
let ctx = JoinAuthCtx::didcomm(applicant_did);
let outcome = dispatch_trust_task_core(state, &ctx, &body).await;
if outcome.status.is_success() {
info!(applicant = %applicant_log, thid = %thid, "join-request processed");
} else {
let (code, reason) = error_doc_summary(&outcome.body);
warn!(
applicant = %applicant_log,
thid = %thid,
status = outcome.status.as_u16(),
code = %code,
reason = reason.as_deref().unwrap_or("<none>"),
"join-request refused — trust-task-error returned, no member or pending request created"
);
}
tt_didcomm_reply(outcome, thid)
}
async fn join_request_accept_handler(
msg: &Message,
auth_sender: Option<String>,
state: &AppState,
) -> Option<Reply> {
let thid = msg.id.clone();
let Some(member_did) = auth_sender else {
return Some(unauthorized_reply(thid));
};
let body = match inbound_doc_bytes(msg) {
Ok(b) => b,
Err(e) => return Some(problem_report(thid, codes::INTERNAL, e)),
};
let ctx = JoinAuthCtx::didcomm(member_did);
let outcome = dispatch_trust_task_core(state, &ctx, &body).await;
tt_didcomm_reply(outcome, thid)
}
async fn join_request_manifest_handler(msg: &Message, state: &AppState) -> Option<Reply> {
let thid = msg.id.clone();
let body = match inbound_doc_bytes(msg) {
Ok(b) => b,
Err(e) => return Some(problem_report(thid, codes::INTERNAL, e)),
};
let ctx = JoinAuthCtx {
transport: JoinTransport::DIDComm,
sender_did: msg.from.clone(),
};
let outcome = dispatch_trust_task_core(state, &ctx, &body).await;
tt_didcomm_reply(outcome, thid)
}
async fn join_request_status_handler(
msg: &Message,
auth_sender: Option<String>,
state: &AppState,
) -> Option<Reply> {
let thid = msg.id.clone();
let Some(applicant_did) = auth_sender else {
return Some(unauthorized_reply(thid));
};
let body = match inbound_doc_bytes(msg) {
Ok(b) => b,
Err(e) => return Some(problem_report(thid, codes::INTERNAL, e)),
};
let ctx = JoinAuthCtx::didcomm(applicant_did);
let outcome = dispatch_trust_task_core(state, &ctx, &body).await;
tt_didcomm_reply(outcome, thid)
}
async fn member_self_remove_handler(
msg: &Message,
auth_sender: Option<String>,
state: &AppState,
) -> Option<Reply> {
let thid = msg.id.clone();
let Some(caller_did) = auth_sender else {
return Some(unauthorized_reply(thid));
};
let body: SelfRemoveBody = match serde_json::from_value(msg.body.clone()) {
Ok(b) => b,
Err(e) => {
return Some(problem_report(
thid,
codes::BAD_REQUEST,
format!("malformed self-remove body: {e}"),
));
}
};
let disposition = match body
.disposition
.as_deref()
.map(parse_disposition)
.transpose()
{
Ok(d) => d,
Err(e) => return Some(problem_report(thid, codes::BAD_REQUEST, e)),
};
let outcome =
match remove_inner(state, &caller_did, &caller_did, disposition, String::new()).await {
Ok(o) => o,
Err(e) => return Some(app_error_report(thid, &e)),
};
let receipt = SelfRemoveReceiptBody {
did: outcome.did,
disposition: outcome.disposition,
removed: outcome.removed,
};
let body = match serde_json::to_value(&receipt) {
Ok(v) => v,
Err(e) => {
return Some(problem_report(
thid,
codes::INTERNAL,
format!("receipt serialise: {e}"),
));
}
};
Some(Reply {
type_: MEMBER_SELF_REMOVE_RECEIPT_TYPE.to_string(),
body,
thid,
})
}
async fn member_vmc_handler(
msg: &Message,
auth_sender: Option<String>,
state: &AppState,
) -> Option<Reply> {
let thid = msg.id.clone();
let Some(member_did) = auth_sender else {
return Some(unauthorized_reply(thid));
};
let body: MemberVmcBody = match serde_json::from_value(msg.body.clone()) {
Ok(b) => b,
Err(e) => {
return Some(problem_report(
thid,
codes::BAD_REQUEST,
format!("malformed member-vmc body: {e}"),
));
}
};
let outcome =
match crate::members::inbound_vmc::receive_member_vmc_inner(state, member_did, body.vc)
.await
{
Ok(o) => o,
Err(e) => return Some(app_error_report(thid, &e)),
};
let receipt = MemberVmcReceiptBody {
member_did: outcome.member_did,
vmc_id: outcome.vmc_id,
status: "stored".to_string(),
};
let body = match serde_json::to_value(&receipt) {
Ok(v) => v,
Err(e) => {
return Some(problem_report(
thid,
codes::INTERNAL,
format!("receipt serialise: {e}"),
));
}
};
Some(Reply {
type_: MEMBER_VMC_RESPONSE_TYPE.to_string(),
body,
thid,
})
}
async fn credential_request_handler(msg: &Message, state: &AppState) -> Option<Reply> {
let thid = msg.id.clone();
let body: RequestBody = match serde_json::from_value(msg.body.clone()) {
Ok(b) => b,
Err(e) => {
return Some(problem_report(
thid,
codes::INTERNAL,
format!("malformed credential request: {e}"),
));
}
};
let response = match crate::credentials::redeem(
&state.join_requests_ks,
&body.credential_request,
chrono::Utc::now(),
)
.await
{
Ok(r) => r,
Err(e) => {
return Some(problem_report(
thid,
codes::INTERNAL,
format!("credential issuance: {e}"),
));
}
};
let issue = IssueBody {
credential_response: Some(response),
sealed: None,
};
let issue_body = match serde_json::to_value(&issue) {
Ok(v) => v,
Err(e) => {
return Some(problem_report(
thid,
codes::INTERNAL,
format!("issue serialise: {e}"),
));
}
};
Some(Reply {
type_: CREDENTIAL_ISSUE_TYPE.to_string(),
body: issue_body,
thid,
})
}
async fn credential_present_handler(msg: &Message, state: &AppState) -> Option<Reply> {
let thid = msg.id.clone();
let body: PresentBody = match serde_json::from_value(msg.body.clone()) {
Ok(b) => b,
Err(e) => {
return Some(problem_report(
thid,
codes::INTERNAL,
format!("malformed present body: {e}"),
));
}
};
let thread_id = match msg.thid.clone() {
Some(t) => t,
None => {
return Some(problem_report(
thid,
codes::INTERNAL,
"present carries no thread id (thid) to correlate its challenge",
));
}
};
let now = chrono::Utc::now();
let challenge = match crate::credentials::present_challenge::consume(
&state.join_requests_ks,
&thread_id,
now,
)
.await
{
Ok(c) => c,
Err(e) => {
return Some(problem_report(
thid,
codes::INTERNAL,
format!("present challenge: {e}"),
));
}
};
let outcome = match crate::routes::join_requests::present::present_and_decide_join(
state,
&body.vp_token,
&challenge.aud,
&challenge.nonce,
JoinTransport::DIDComm,
now,
)
.await
{
Ok(o) => o,
Err(e) => {
return Some(problem_report(
thid,
codes::INTERNAL,
format!("present decision: {e}"),
));
}
};
if let Some(admit) = outcome.admit.as_deref() {
let holder_did = outcome.request.applicant_did.clone();
if let Err(e) =
crate::credentials::delivery::deliver_membership_credentials(state, &holder_did, admit)
.await
{
warn!(holder = %holder_did, request = %outcome.request.id, error = %e, "membership-credential delivery failed; credential is issued and can be re-delivered");
} else {
info!(holder = %holder_did, request = %outcome.request.id, "delivered membership credentials to holder over DIDComm");
}
}
let status = serde_json::to_value(outcome.request.status)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default();
let receipt = JoinRequestSubmitReceiptBody {
request_id: outcome.request.id,
status,
};
let receipt_body = match serde_json::to_value(&receipt) {
Ok(v) => v,
Err(e) => {
return Some(problem_report(
thid,
codes::INTERNAL,
format!("receipt serialise: {e}"),
));
}
};
Some(Reply {
type_: JOIN_REQUEST_SUBMIT_RECEIPT_TYPE.to_string(),
body: receipt_body,
thid,
})
}
fn parse_disposition(s: &str) -> Result<Disposition, String> {
match s.to_ascii_lowercase().as_str() {
"purge" => Ok(Disposition::Purge),
"tombstone" => Ok(Disposition::Tombstone),
"historical" => Ok(Disposition::Historical),
"policydefault" => Ok(Disposition::PolicyDefault),
other => Err(format!(
"unknown disposition '{other}' (expected purge|tombstone|historical|policydefault)"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn problem_report_details_extracts_code_comment_and_args() {
let (code, comment, args) = problem_report_details(&json!({
"code": "e.p.xfer.cant-use-endpoint",
"comment": "Unable to use the {1} endpoint for {2}.",
"args": ["https://x.example/finance", "did:example:1234"],
}));
assert_eq!(code, "e.p.xfer.cant-use-endpoint");
assert_eq!(comment, "Unable to use the {1} endpoint for {2}.");
assert_eq!(args, "https://x.example/finance, did:example:1234");
}
#[test]
fn problem_report_details_marks_missing_fields() {
let (code, comment, args) = problem_report_details(&json!({}));
assert_eq!(code, "<none>");
assert_eq!(comment, "<none>");
assert_eq!(args, "<none>");
}
#[test]
fn app_error_maps_to_typed_problem_report_codes() {
assert_eq!(
app_error_code(&AppError::Forbidden("nope".into())),
codes::FORBIDDEN
);
assert_eq!(
app_error_code(&AppError::Unauthorized("nope".into())),
codes::UNAUTHORIZED
);
assert_eq!(
app_error_code(&AppError::NotFound("nope".into())),
codes::NOT_FOUND
);
assert_eq!(
app_error_code(&AppError::Conflict("dup".into())),
codes::CONFLICT
);
assert_eq!(
app_error_code(&AppError::Validation("bad".into())),
codes::BAD_REQUEST
);
assert_eq!(
app_error_code(&AppError::Internal("boom".into())),
codes::INTERNAL
);
}
#[test]
fn problem_report_body_is_code_and_comment() {
let body = problem_report_body(codes::BAD_REQUEST, "malformed body");
let (code, comment) = vta_sdk::protocols::extract_problem_report(&body);
assert_eq!(code, codes::BAD_REQUEST);
assert_eq!(comment, "malformed body");
}
}