use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use serde_json::Value;
use tracing::{debug, warn};
use vta_sdk::client::VtaClient;
use crate::issued_credential::verify_issued_credential;
use crate::{CredentialKind, config::Config};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SyncReport {
pub stored: usize,
pub already_held: usize,
pub failed: usize,
pub unverified: usize,
}
impl SyncReport {
#[must_use]
pub fn is_noop(&self) -> bool {
self.stored == 0 && self.failed == 0 && self.unverified == 0
}
}
pub async fn sync_membership_credentials(
config: &Config,
client: &VtaClient,
resolver: &DIDCacheClient,
) -> SyncReport {
let held: Vec<(String, String, Value)> = config
.account
.memberships()
.filter_map(|c| {
let vc = c.credentials.get(&CredentialKind::Membership)?;
let id = vc.get("id").and_then(Value::as_str)?;
Some((id.to_string(), c.vtc_did.clone(), vc.clone()))
})
.collect();
if held.is_empty() {
return SyncReport::default();
}
let in_vault = match client.cred_vault_query(held_query_filter()).await {
Ok(listing) => held_ids(&listing),
Err(e) => {
debug!("could not read held membership credentials ({e}); storing regardless");
Vec::new()
}
};
let mut report = SyncReport::default();
for (id, vtc_did, credential) in held {
if in_vault.iter().any(|held| held == &id) {
report.already_held += 1;
continue;
}
let credential = match verify_issued_credential(
credential,
&vtc_did,
resolver,
chrono::Utc::now(),
)
.await
{
Ok(verified) => verified.into_value(),
Err(e) => {
warn!(reason = %e, "a held membership credential did not verify; not storing it");
report.unverified += 1;
continue;
}
};
match client.cred_vault_receive(credential, None).await {
Ok(_) => {
debug!(id = %id, "stored a membership credential in the vault");
report.stored += 1;
}
Err(e) => {
warn!(id = %id, "could not store a membership credential: {e}");
report.failed += 1;
}
}
}
report
}
fn held_query_filter() -> Value {
serde_json::json!({ "purpose": "membership" })
}
fn held_ids(listing: &Value) -> Vec<String> {
let array = if let Some(arr) = listing.as_array() {
arr.clone()
} else {
["credentials", "items", "results"]
.iter()
.find_map(|k| listing.get(*k).and_then(Value::as_array))
.cloned()
.unwrap_or_default()
};
array
.into_iter()
.filter_map(|d| {
d.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| d.as_str().map(str::to_string))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pass_with_nothing_to_do_is_a_noop() {
assert!(SyncReport::default().is_noop());
assert!(
SyncReport {
stored: 0,
already_held: 4,
failed: 0,
unverified: 0,
}
.is_noop(),
"an already-synced account must not report activity every launch"
);
assert!(
!SyncReport {
unverified: 1,
..SyncReport::default()
}
.is_noop(),
"a credential that did not verify is worth reporting"
);
}
#[test]
fn stores_and_failures_are_both_worth_reporting() {
assert!(
!SyncReport {
stored: 1,
already_held: 0,
failed: 0,
unverified: 0,
}
.is_noop()
);
assert!(
!SyncReport {
stored: 0,
already_held: 0,
failed: 1,
unverified: 0,
}
.is_noop(),
"a failure costs future recoverability and must be visible"
);
}
#[test]
fn the_sync_query_carries_a_filter() {
let filter = held_query_filter();
let obj = filter.as_object().expect("an object");
assert!(
!obj.is_empty(),
"a filterless vault query is refused by contract"
);
assert_eq!(
obj.get("purpose").and_then(Value::as_str),
Some("membership")
);
}
#[test]
fn ids_are_read_from_whichever_envelope_the_vault_used() {
for key in ["credentials", "items", "results"] {
let listing = serde_json::json!({ key: [{ "id": "vmc-1" }] });
assert_eq!(held_ids(&listing), vec!["vmc-1".to_string()], "key {key}");
}
assert_eq!(
held_ids(&serde_json::json!([{ "id": "vmc-1" }])),
vec!["vmc-1".to_string()]
);
}
#[test]
fn an_unrecognised_envelope_yields_nothing() {
assert!(held_ids(&serde_json::json!({ "unexpected": [{ "id": "x" }] })).is_empty());
}
#[test]
fn a_descriptor_without_an_id_is_skipped() {
let listing = serde_json::json!({ "credentials": [{ "types": ["X"] }, { "id": "ok" }] });
assert_eq!(held_ids(&listing), vec!["ok".to_string()]);
}
}