use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use trust_tasks_rs::{RejectReason, TrustTask};
use uuid::Uuid;
use vti_common::acl::Capability;
use vti_common::vault::{LifecycleError, VaultStatus};
use crate::auth::AuthClaims;
use crate::error::AppError;
use crate::server::AppState;
use crate::vault::model::{CredentialPurpose, CredentialStatus};
use crate::vault::query::{CredentialDescriptor, CredentialQuery, search};
use crate::vault::{di_verify, receive, storage};
use super::helpers::{
TrustTaskOutcome, app_error_to_reject, parse_payload, reject_with, success_response,
};
async fn require_cap(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
cap: Capability,
action: &str,
) -> Result<(), TrustTaskOutcome> {
super::helpers::require_capability(state, auth, doc, cap, &format!("credential-vault {action}"))
.await
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ReceiveBody {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) credential: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) credential_base64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) context_id: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ReceiveResponse {
pub(super) id: String,
pub(super) types: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) purpose: Option<CredentialPurpose>,
pub(super) status: CredentialStatus,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct QueryResponse {
pub(super) credentials: Vec<CredentialDescriptor>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct GetBody {
pub(super) id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct GetResponse {
pub(super) credential: Value,
}
pub(super) async fn handle_receive(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::VaultWrite, "receive").await {
return r;
}
let req: ReceiveBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let custody_context = match resolve_custody_context(auth, req.context_id) {
Ok(c) => c,
Err(e) => return app_error_to_reject(&doc, e),
};
let now = Utc::now();
let provenance = Some("vault/credentials/receive/0.1".to_string());
let mut stored = match req.format.as_deref() {
None | Some("ldp_vc") => {
let Some(credential) = req.credential else {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "a Data-Integrity credential must be supplied as `credential`"
.to_string(),
},
);
};
if req.credential_base64.is_some() {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "supply exactly one of `credential` or `credentialBase64`"
.to_string(),
},
);
}
let id = resolve_storage_id(req.id, &credential);
let issuer_pub =
match di_verify::resolve_di_issuer_key(state.did_resolver.as_ref(), &credential)
.await
{
Ok(k) => k,
Err(e) => return app_error_to_reject(&doc, e),
};
let body = match serde_json::to_vec(&credential) {
Ok(b) => b,
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("credential serialise: {e}"),
},
);
}
};
match receive::receive_di_vc(&state.vault_ks, &id, &body, &issuer_pub, provenance, now)
.await
{
Ok(s) => s,
Err(e) => return app_error_to_reject(&doc, e),
}
}
Some("mso_mdoc") => {
let Some(b64) = req.credential_base64 else {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "an mdoc must be supplied as `credentialBase64` (CBOR \
IssuerSigned), not `credential`"
.to_string(),
},
);
};
if req.credential.is_some() {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "supply exactly one of `credential` or `credentialBase64`"
.to_string(),
},
);
}
let body = match URL_SAFE_NO_PAD.decode(b64.as_bytes()) {
Ok(b) => b,
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("`credentialBase64` is not base64url-no-pad: {e}"),
},
);
}
};
let issued = match affinidi_mdoc::IssuerSigned::from_cbor_bytes(&body) {
Ok(i) => i,
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("not a decodable mdoc IssuerSigned: {e}"),
},
);
}
};
let issuer_pub = match state
.mdoc_trust
.resolve_issuer_key(&issued.issuer_auth, now)
{
Ok(k) => k,
Err(e) => return app_error_to_reject(&doc, e),
};
let device_point = match vta_vault::mdoc_trust::mdoc_device_key_sec1(&issued.mso) {
Ok(p) => p,
Err(e) => return app_error_to_reject(&doc, e),
};
let device_mb =
vta_keys::encode_public_multibase(&vta_sdk::keys::KeyType::P256, &device_point);
let device_key = match crate::operations::keys::find_key_by_public_multibase(
&state.keys_ks,
&device_mb,
)
.await
{
Ok(Some(k)) => k,
Ok(None) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "this VTA does not hold the mdoc's MSO deviceKey, so the \
credential could never be presented with holder binding"
.to_string(),
},
);
}
Err(e) => return app_error_to_reject(&doc, e),
};
if let Some(ctx) = device_key.context_id.as_deref()
&& let Err(e) = auth.require_context(ctx)
{
return app_error_to_reject(&doc, e);
}
let id = req
.id
.unwrap_or_else(|| format!("urn:uuid:{}", Uuid::new_v4()));
match receive::receive_mdoc(
&state.vault_ks,
&id,
&body,
&issuer_pub,
&device_key.key_id,
provenance,
now,
)
.await
{
Ok(s) => s,
Err(e) => return app_error_to_reject(&doc, e),
}
}
Some(other) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!(
"unsupported credential format `{other}` (expected `ldp_vc` or \
`mso_mdoc`)"
),
},
);
}
};
if custody_context.is_some() {
stored.context_id = custody_context;
if let Err(e) = storage::put(&state.vault_ks, &stored).await {
return app_error_to_reject(&doc, e);
}
}
success_response(
&doc,
ReceiveResponse {
id: stored.id,
types: stored.types,
purpose: stored.purpose,
status: stored.status,
},
)
}
pub(super) async fn handle_query(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::VaultRead, "query").await {
return r;
}
let query: CredentialQuery = match parse_payload(&doc) {
Ok(q) => q,
Err(resp) => return resp,
};
match search(&state.vault_ks, &query, &auth.act_scope()).await {
Ok(credentials) => success_response(&doc, QueryResponse { credentials }),
Err(e) => app_error_to_reject(&doc, e),
}
}
fn resolve_storage_id(explicit: Option<String>, credential: &Value) -> String {
explicit
.or_else(|| {
credential
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| format!("urn:uuid:{}", Uuid::new_v4()))
}
fn resolve_custody_context(
auth: &AuthClaims,
override_ctx: Option<String>,
) -> Result<Option<String>, AppError> {
match override_ctx {
Some(ctx) => {
if !auth.has_context_access(&ctx) {
return Err(AppError::Forbidden(format!(
"caller cannot receive a credential into context {ctx}"
)));
}
Ok(Some(ctx))
}
None if auth.allowed_contexts.len() == 1 => Ok(Some(auth.allowed_contexts[0].clone())),
None => Ok(None),
}
}
pub(super) async fn handle_get(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::VaultRead, "get").await {
return r;
}
let req: GetBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
match storage::get(&state.vault_ks, &req.id).await {
Ok(Some(stored))
if stored.is_active()
&& crate::vault::query::caller_may_access_custody(
&auth.act_scope(),
stored.context_id.as_deref(),
) =>
{
match serde_json::from_slice::<Value>(&stored.body) {
Ok(credential) => success_response(&doc, GetResponse { credential }),
Err(e) => reject_with(
&doc,
RejectReason::InternalError {
reason: format!("stored credential body is not JSON: {e}"),
},
),
}
}
Ok(_) => reject_with(
&doc,
RejectReason::TaskFailed {
reason: "credential not found".to_string(),
details: None,
},
),
Err(e) => app_error_to_reject(&doc, e),
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct CredLifecycleBody {
pub(super) id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[allow(dead_code)] pub(super) reason: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct CredDeleteBody {
pub(super) id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[allow(dead_code)] pub(super) reason: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub(super) force: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct CredLifecycleResponse {
pub(super) id: String,
pub(super) lifecycle: VaultStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) grace_until: Option<String>,
}
fn cred_not_found(doc: &TrustTask<Value>, verb: &str, id: &str) -> TrustTaskOutcome {
reject_with(
doc,
RejectReason::TaskFailed {
reason: format!("vault/credentials/{verb}:not_found — no credential at id {id}"),
details: None,
},
)
}
fn cred_lifecycle_reject(
doc: &TrustTask<Value>,
verb: &str,
id: &str,
err: LifecycleError,
) -> TrustTaskOutcome {
let hint = match err {
LifecycleError::NotActive => "credential is not active (already archived or deleted)",
LifecycleError::NotArchived => "credential is not archived",
LifecycleError::AlreadyDeleted => {
"credential is already in the trash — restore it or purge it"
}
LifecycleError::NotDeleted => "credential is not in the trash",
LifecycleError::GraceExpired => {
"the grace window has elapsed — the credential has been (or is about to be) purged"
}
};
reject_with(
doc,
RejectReason::TaskFailed {
reason: format!("vault/credentials/{verb}:{} — {hint} (id {id})", err.code()),
details: None,
},
)
}
fn cred_lifecycle_response(cred: &crate::vault::model::StoredCredential) -> CredLifecycleResponse {
CredLifecycleResponse {
id: cred.id.clone(),
lifecycle: cred.lifecycle,
grace_until: cred.grace_until.clone(),
}
}
pub(super) async fn handle_archive(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let now = Utc::now().to_rfc3339();
cred_transition(state, auth, doc, "archive", move |cred| cred.archive(&now)).await
}
pub(super) async fn handle_unarchive(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
cred_transition(state, auth, doc, "unarchive", |cred| cred.unarchive()).await
}
pub(super) async fn handle_restore(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let now = Utc::now().to_rfc3339();
cred_transition(state, auth, doc, "restore", move |cred| cred.restore(&now)).await
}
async fn cred_transition(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
verb: &str,
transition: impl FnOnce(&mut crate::vault::model::StoredCredential) -> Result<(), LifecycleError>,
) -> TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::CredentialWrite, verb).await {
return r;
}
let req: CredLifecycleBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let mut cred = match storage::get(&state.vault_ks, &req.id).await {
Ok(Some(c)) => c,
Ok(None) => return cred_not_found(&doc, verb, &req.id),
Err(e) => return app_error_to_reject(&doc, e),
};
if !crate::vault::query::caller_may_access_custody(
&auth.act_scope(),
cred.context_id.as_deref(),
) {
return cred_not_found(&doc, verb, &req.id);
}
if let Err(e) = transition(&mut cred) {
return cred_lifecycle_reject(&doc, verb, &req.id, e);
}
if let Err(e) = storage::put(&state.vault_ks, &cred).await {
return app_error_to_reject(&doc, e);
}
success_response(&doc, cred_lifecycle_response(&cred))
}
pub(super) async fn handle_delete(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::CredentialWrite, "delete").await {
return r;
}
let req: CredDeleteBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if req.force {
match storage::get(&state.vault_ks, &req.id).await {
Ok(Some(cred))
if !crate::vault::query::caller_may_access_custody(
&auth.act_scope(),
cred.context_id.as_deref(),
) =>
{
return cred_not_found(&doc, "delete", &req.id);
}
Err(e) => return app_error_to_reject(&doc, e),
_ => {}
}
if let Err(e) = storage::delete(&state.vault_ks, &req.id).await {
return app_error_to_reject(&doc, e);
}
return success_response(
&doc,
CredLifecycleResponse {
id: req.id,
lifecycle: VaultStatus::Deleted,
grace_until: None,
},
);
}
let mut cred = match storage::get(&state.vault_ks, &req.id).await {
Ok(Some(c)) => c,
Ok(None) => return cred_not_found(&doc, "delete", &req.id),
Err(e) => return app_error_to_reject(&doc, e),
};
if !crate::vault::query::caller_may_access_custody(
&auth.act_scope(),
cred.context_id.as_deref(),
) {
return cred_not_found(&doc, "delete", &req.id);
}
let now = Utc::now();
let grace_days = state.config.read().await.vault.grace_days;
let grace_until = (now + chrono::Duration::days(grace_days as i64)).to_rfc3339();
if let Err(e) = cred.soft_delete(&now.to_rfc3339(), &grace_until) {
return cred_lifecycle_reject(&doc, "delete", &req.id, e);
}
if let Err(e) = storage::put(&state.vault_ks, &cred).await {
return app_error_to_reject(&doc, e);
}
success_response(&doc, cred_lifecycle_response(&cred))
}
pub(super) async fn handle_purge(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::CredentialWrite, "purge").await {
return r;
}
let req: CredLifecycleBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
match storage::get(&state.vault_ks, &req.id).await {
Ok(Some(cred)) => {
if !crate::vault::query::caller_may_access_custody(
&auth.act_scope(),
cred.context_id.as_deref(),
) {
return cred_not_found(&doc, "purge", &req.id);
}
}
Ok(None) => return cred_not_found(&doc, "purge", &req.id),
Err(e) => return app_error_to_reject(&doc, e),
}
if let Err(e) = storage::delete(&state.vault_ks, &req.id).await {
return app_error_to_reject(&doc, e);
}
success_response(
&doc,
CredLifecycleResponse {
id: req.id,
lifecycle: VaultStatus::Deleted,
grace_until: None,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn storage_id_prefers_explicit_then_vc_id_then_uuid() {
let vc = json!({ "id": "urn:uuid:from-vc", "type": ["InvitationCredential"] });
assert_eq!(
resolve_storage_id(Some("explicit-id".into()), &vc),
"explicit-id"
);
assert_eq!(resolve_storage_id(None, &vc), "urn:uuid:from-vc");
let generated = resolve_storage_id(None, &json!({ "type": ["X"] }));
assert!(
generated.starts_with("urn:uuid:"),
"fallback id is a urn:uuid: {generated}"
);
}
async fn seed_credential(
vault_ks: &crate::store::KeyspaceHandle,
id: &str,
context_id: Option<&str>,
) {
use crate::vault::model::{CredentialFormat, CredentialStatus, StoredCredential};
use vti_common::vault::VaultStatus;
let cred = StoredCredential {
id: id.into(),
format: CredentialFormat::EddsaJcs2022,
types: vec!["MembershipCredential".into()],
schema_id: None,
community_did: None,
context_id: context_id.map(str::to_string),
subject_did: None,
issuer_did: Some("did:key:zIssuer".into()),
purpose: None,
status: CredentialStatus::Unknown,
valid_from: None,
valid_until: None,
received_at: "2026-01-01T00:00:00Z".into(),
source: None,
tags: Default::default(),
body: serde_json::to_vec(&json!({"id": id})).unwrap(),
lifecycle: VaultStatus::Active,
archived_at: None,
deleted_at: None,
grace_until: None,
};
crate::vault::storage::put(vault_ks, &cred).await.unwrap();
}
fn get_doc(id: &str) -> TrustTask<Value> {
let uri: trust_tasks_rs::TypeUri = vta_sdk::trust_tasks::TASK_VAULT_CREDENTIALS_GET_0_1
.parse()
.expect("get uri");
TrustTask::new("urn:uuid:test-get", uri, json!({ "id": id }))
}
fn query_doc() -> TrustTask<Value> {
let uri: trust_tasks_rs::TypeUri = vta_sdk::trust_tasks::TASK_VAULT_CREDENTIALS_QUERY_0_1
.parse()
.expect("query uri");
TrustTask::new(
"urn:uuid:test-query",
uri,
json!({ "issuerDid": "did:key:zIssuer" }),
)
}
#[tokio::test]
async fn get_refuses_a_credential_owned_by_another_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-in-ctx-b", Some("ctx-b")).await;
let auth = auth_scoped(&["ctx-a"]);
let outcome = handle_get(&state, &auth, get_doc("cred-in-ctx-b")).await;
let body = String::from_utf8_lossy(&outcome.body).to_string();
assert!(
!body.contains("cred-in-ctx-b") || body.contains("not found"),
"a ctx-a caller must not receive a ctx-b credential body; got {body}"
);
}
#[tokio::test]
async fn get_allows_a_credential_owned_by_the_callers_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-in-ctx-a", Some("ctx-a")).await;
let auth = auth_scoped(&["ctx-a"]);
let outcome = handle_get(&state, &auth, get_doc("cred-in-ctx-a")).await;
let body = String::from_utf8_lossy(&outcome.body).to_string();
assert!(
body.contains("cred-in-ctx-a"),
"the caller's own context must still be readable; got {body}"
);
}
#[tokio::test]
async fn query_excludes_credentials_owned_by_another_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-in-ctx-a", Some("ctx-a")).await;
seed_credential(&state.vault_ks, "cred-in-ctx-b", Some("ctx-b")).await;
let auth = auth_scoped(&["ctx-a"]);
let outcome = handle_query(&state, &auth, query_doc()).await;
let body = String::from_utf8_lossy(&outcome.body).to_string();
assert!(
body.contains("cred-in-ctx-a"),
"own-context credential must be returned; got {body}"
);
assert!(
!body.contains("cred-in-ctx-b"),
"another context's credential must not be returned; got {body}"
);
}
#[tokio::test]
async fn super_admin_still_reads_every_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-anywhere", Some("ctx-b")).await;
let auth = auth_scoped(&[]);
let outcome = handle_get(&state, &auth, get_doc("cred-anywhere")).await;
let body = String::from_utf8_lossy(&outcome.body).to_string();
assert!(
body.contains("cred-anywhere"),
"super admin must still read any context; got {body}"
);
}
fn lifecycle_doc(task: &str, id: &str, force: bool) -> TrustTask<Value> {
let uri: trust_tasks_rs::TypeUri = task.parse().expect("uri");
TrustTask::new(
"urn:uuid:test-lifecycle",
uri,
json!({ "id": id, "force": force }),
)
}
#[tokio::test]
async fn archive_refuses_a_credential_owned_by_another_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-in-ctx-b", Some("ctx-b")).await;
let auth = auth_scoped(&["ctx-a"]);
let doc = lifecycle_doc(
vta_sdk::trust_tasks::TASK_VAULT_CREDENTIALS_ARCHIVE_0_1,
"cred-in-ctx-b",
false,
);
let _ = handle_archive(&state, &auth, doc).await;
let after = crate::vault::storage::get(&state.vault_ks, "cred-in-ctx-b")
.await
.unwrap()
.expect("credential must survive");
assert!(
after.is_active(),
"a ctx-a caller must not archive a ctx-b credential"
);
}
#[tokio::test]
async fn force_delete_refuses_a_credential_owned_by_another_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-in-ctx-b", Some("ctx-b")).await;
let auth = auth_scoped(&["ctx-a"]);
let doc = lifecycle_doc(
vta_sdk::trust_tasks::TASK_VAULT_CREDENTIALS_DELETE_0_1,
"cred-in-ctx-b",
true,
);
let _ = handle_delete(&state, &auth, doc).await;
assert!(
crate::vault::storage::get(&state.vault_ks, "cred-in-ctx-b")
.await
.unwrap()
.is_some(),
"a ctx-a caller must not hard-delete a ctx-b credential"
);
}
#[tokio::test]
async fn force_delete_allows_the_callers_own_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-in-ctx-a", Some("ctx-a")).await;
let auth = auth_scoped(&["ctx-a"]);
let doc = lifecycle_doc(
vta_sdk::trust_tasks::TASK_VAULT_CREDENTIALS_DELETE_0_1,
"cred-in-ctx-a",
true,
);
let _ = handle_delete(&state, &auth, doc).await;
assert!(
crate::vault::storage::get(&state.vault_ks, "cred-in-ctx-a")
.await
.unwrap()
.is_none(),
"the caller's own credential must still be deletable"
);
}
fn auth_scoped(ctxs: &[&str]) -> AuthClaims {
AuthClaims {
role: crate::acl::Role::Admin,
allowed_contexts: ctxs.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
#[test]
fn custody_auto_binds_to_callers_single_context() {
let r = resolve_custody_context(&auth_scoped(&["acme"]), None).unwrap();
assert_eq!(r.as_deref(), Some("acme"));
}
#[test]
fn custody_unscoped_for_super_admin_and_multi_context() {
assert_eq!(
resolve_custody_context(&auth_scoped(&[]), None).unwrap(),
None
);
assert_eq!(
resolve_custody_context(&auth_scoped(&["a", "b"]), None).unwrap(),
None
);
}
#[test]
fn custody_override_must_be_accessible() {
let ok = resolve_custody_context(&auth_scoped(&["acme"]), Some("acme".into())).unwrap();
assert_eq!(ok.as_deref(), Some("acme"));
let sa = resolve_custody_context(&auth_scoped(&[]), Some("acme".into())).unwrap();
assert_eq!(sa.as_deref(), Some("acme"));
let err = resolve_custody_context(&auth_scoped(&["acme"]), Some("other".into()));
assert!(matches!(err, Err(AppError::Forbidden(_))), "{err:?}");
}
#[test]
fn receive_body_parses_with_and_without_id() {
let with_id: ReceiveBody =
serde_json::from_value(json!({ "credential": {"id": "x"}, "id": "y" })).unwrap();
assert_eq!(with_id.id.as_deref(), Some("y"));
let without: ReceiveBody =
serde_json::from_value(json!({ "credential": {"id": "x"} })).unwrap();
assert_eq!(without.id, None);
}
}
#[cfg(test)]
mod receive_body_wire_tests {
use super::*;
use serde_json::json;
#[test]
fn a_pre_existing_body_still_deserializes_and_stays_on_the_di_path() {
let body: ReceiveBody = serde_json::from_value(json!({
"credential": {"id": "urn:uuid:abc", "type": ["VerifiableCredential"]},
"id": "urn:uuid:abc"
}))
.expect("an existing client body must still parse");
assert!(body.credential.is_some());
assert!(body.credential_base64.is_none());
assert!(
body.format.is_none(),
"absent format must stay absent — it is what selects the DI path"
);
}
#[test]
fn the_binary_field_is_camel_case_on_the_wire() {
let ok: ReceiveBody = serde_json::from_value(json!({
"credentialBase64": "AAAA",
"format": "mso_mdoc"
}))
.expect("camelCase parses");
assert_eq!(ok.credential_base64.as_deref(), Some("AAAA"));
let wrong: ReceiveBody = serde_json::from_value(json!({
"credential_base64": "AAAA",
"format": "mso_mdoc"
}))
.expect("unknown fields are ignored today");
assert!(
wrong.credential_base64.is_none(),
"snake_case must not bind — the wire contract is camelCase"
);
}
#[test]
fn the_mdoc_format_tag_matches_the_stored_credential_format() {
let body: ReceiveBody =
serde_json::from_value(json!({"credentialBase64": "AA", "format": "mso_mdoc"}))
.unwrap();
let stored = serde_json::to_string(&vta_vault::model::CredentialFormat::MsoMdoc).unwrap();
assert_eq!(
format!("\"{}\"", body.format.unwrap()),
stored,
"the wire format tag and the stored format tag must be the same token"
);
}
}