use crate::CredentialKind;
use crate::config::KeyTypes;
use crate::errors::OpenVTCError;
use crate::relationships::Relationships;
use crate::tasks::Tasks;
use crate::vrc::Vrcs;
use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use uuid::Uuid;
pub type VtcDid = String;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct PersonaId(pub Uuid);
impl PersonaId {
pub fn new() -> Self {
PersonaId(Uuid::new_v4())
}
}
impl Default for PersonaId {
fn default() -> Self {
PersonaId::new()
}
}
impl std::fmt::Display for PersonaId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyRef {
pub key_id: String,
pub purpose: KeyTypes,
pub created_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PersonaRecord {
pub persona_id: PersonaId,
pub did: String,
#[serde(default)]
pub did_document: Option<affinidi_tdk::did_common::Document>,
pub key_refs: Vec<KeyRef>,
pub mediator_did: Option<String>,
pub origin_context_id: String,
pub created_at: DateTime<Utc>,
pub label: Option<String>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum CommunityStatus {
Pending {
request_id: Uuid,
},
Active,
Left,
Withdrawn,
Rejected,
Removed,
Expired,
}
impl CommunityStatus {
pub fn is_active(&self) -> bool {
matches!(self, CommunityStatus::Active)
}
pub fn is_read_only(&self) -> bool {
!self.is_active()
}
pub fn is_inactive(&self) -> bool {
matches!(
self,
CommunityStatus::Left
| CommunityStatus::Withdrawn
| CommunityStatus::Rejected
| CommunityStatus::Removed
| CommunityStatus::Expired
)
}
pub fn needs_attention(&self) -> bool {
matches!(
self,
CommunityStatus::Pending { .. }
| CommunityStatus::Rejected
| CommunityStatus::Removed
| CommunityStatus::Expired
)
}
pub fn requires_live_session(&self) -> bool {
matches!(
self,
CommunityStatus::Active | CommunityStatus::Pending { .. }
)
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DecisionEvidence {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decided_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decided_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disposition: Option<String>,
}
impl DecisionEvidence {
pub fn is_empty(&self) -> bool {
self.code.is_none()
&& self.reason.is_none()
&& self.decided_by.is_none()
&& self.decided_at.is_none()
&& self.disposition.is_none()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RelationshipIdentifierDefault {
Attributed,
Pairwise,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(from = "CommunityRecordShadow")]
pub struct CommunityRecord {
pub vtc_did: VtcDid,
pub display_name: Option<String>,
pub sub_context_id: String,
pub persona_ref: PersonaId,
pub status: CommunityStatus,
#[serde(default)]
pub favourite: bool,
#[serde(default)]
pub archived: bool,
#[serde(default)]
pub acknowledged: bool,
pub member_since: Option<DateTime<Utc>>,
pub requested_at: Option<DateTime<Utc>>,
#[serde(default)]
pub receipt_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approved_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential_delivery: Option<CredentialDelivery>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submit_transport: Option<crate::didcomm::MessagingTransport>,
#[serde(default)]
pub request_id_confirmed: bool,
#[serde(default)]
pub relationships: Relationships,
#[serde(default)]
pub tasks: Tasks,
#[serde(default)]
pub vrcs_issued: Vrcs,
#[serde(default)]
pub vrcs_received: Vrcs,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub credentials: BTreeMap<CredentialKind, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub member_vmc: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decision: Option<DecisionEvidence>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relationship_identifier_default: Option<RelationshipIdentifierDefault>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub retired_credentials: Vec<RetiredCredential>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Deserialize)]
struct CommunityRecordShadow {
vtc_did: VtcDid,
display_name: Option<String>,
sub_context_id: String,
persona_ref: PersonaId,
status: CommunityStatus,
#[serde(default)]
favourite: bool,
#[serde(default)]
archived: bool,
#[serde(default)]
acknowledged: bool,
member_since: Option<DateTime<Utc>>,
requested_at: Option<DateTime<Utc>>,
#[serde(default)]
receipt_at: Option<DateTime<Utc>>,
#[serde(default)]
approved_at: Option<DateTime<Utc>>,
#[serde(default)]
credential_delivery: Option<CredentialDelivery>,
#[serde(default)]
submit_transport: Option<crate::didcomm::MessagingTransport>,
#[serde(default)]
request_id_confirmed: bool,
#[serde(default)]
relationships: Relationships,
#[serde(default)]
tasks: Tasks,
#[serde(default)]
vrcs_issued: Vrcs,
#[serde(default)]
vrcs_received: Vrcs,
#[serde(default)]
credentials: BTreeMap<String, serde_json::Value>,
#[serde(default)]
member_vmc: Option<serde_json::Value>,
#[serde(default)]
decision: Option<DecisionEvidence>,
#[serde(default)]
relationship_identifier_default: Option<RelationshipIdentifierDefault>,
#[serde(default)]
retired_credentials: Vec<RetiredCredential>,
#[serde(default)]
membership_credential: Option<serde_json::Value>,
#[serde(default)]
role_credential: Option<serde_json::Value>,
#[serde(flatten, default)]
extra: serde_json::Map<String, serde_json::Value>,
}
impl From<CommunityRecordShadow> for CommunityRecord {
fn from(shadow: CommunityRecordShadow) -> Self {
let mut credentials: BTreeMap<CredentialKind, serde_json::Value> = BTreeMap::new();
for (key, vc) in shadow.credentials {
match CredentialKind::from_config_key(&key) {
Some(kind) => {
credentials.insert(kind, vc);
}
None => tracing::warn!(
credential_kind = %key,
"dropping unknown stored credential kind",
),
}
}
if let Some(vmc) = shadow.membership_credential {
credentials.entry(CredentialKind::Membership).or_insert(vmc);
}
if let Some(role) = shadow.role_credential {
credentials.entry(CredentialKind::Role).or_insert(role);
}
let mut retired_credentials = shadow.retired_credentials;
let now = Utc::now();
credentials.retain(|kind, vc| match crate::dtg::nonconformance(vc) {
None => true,
Some(reason) => {
RetiredCredential::record(
&mut retired_credentials,
kind.config_key(),
vc,
reason,
now,
);
false
}
});
let member_vmc = match shadow.member_vmc {
Some(vc) => match crate::dtg::nonconformance(&vc) {
None => Some(vc),
Some(reason) => {
RetiredCredential::record(
&mut retired_credentials,
RetiredCredential::MEMBER_ACKNOWLEDGEMENT,
&vc,
reason,
now,
);
None
}
},
None => None,
};
CommunityRecord {
extra: shadow.extra,
vtc_did: shadow.vtc_did,
display_name: shadow.display_name,
sub_context_id: shadow.sub_context_id,
persona_ref: shadow.persona_ref,
status: shadow.status,
favourite: shadow.favourite,
archived: shadow.archived,
acknowledged: shadow.acknowledged,
member_since: shadow.member_since,
requested_at: shadow.requested_at,
receipt_at: shadow.receipt_at,
approved_at: shadow.approved_at,
credential_delivery: shadow.credential_delivery,
submit_transport: shadow.submit_transport,
request_id_confirmed: shadow.request_id_confirmed,
relationships: shadow.relationships,
tasks: shadow.tasks,
vrcs_issued: shadow.vrcs_issued,
vrcs_received: shadow.vrcs_received,
credentials,
member_vmc,
decision: shadow.decision,
relationship_identifier_default: shadow.relationship_identifier_default,
retired_credentials,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetiredCredential {
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential_id: Option<String>,
pub reason: String,
pub retired_at: DateTime<Utc>,
}
impl RetiredCredential {
pub const MEMBER_ACKNOWLEDGEMENT: &'static str = "Acknowledgement";
fn record(
into: &mut Vec<RetiredCredential>,
kind: &str,
vc: &serde_json::Value,
reason: String,
now: DateTime<Utc>,
) {
let credential_id = vc
.get("id")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
tracing::warn!(
credential_kind = %kind,
credential_id = credential_id.as_deref().unwrap_or("-"),
%reason,
"setting aside a stored credential that does not conform to DTG Credentials v1 — \
the community must re-issue it"
);
if into
.iter()
.any(|r| r.kind == kind && r.credential_id == credential_id)
{
return;
}
into.push(RetiredCredential {
kind: kind.to_string(),
credential_id,
reason,
retired_at: now,
});
}
}
pub const PENDING_TIMEOUT_DAYS: i64 = 7;
pub const PENDING_ACK_GRACE_SECS: i64 = 120;
impl CommunityRecord {
pub fn clear_retired(&mut self, kind: &str) {
self.retired_credentials.retain(|r| r.kind != kind);
}
pub fn new_pending(
vtc_did: VtcDid,
display_name: Option<String>,
sub_context_id: String,
persona_ref: PersonaId,
request_id: Uuid,
now: DateTime<Utc>,
) -> Self {
CommunityRecord {
extra: serde_json::Map::new(),
vtc_did,
display_name,
sub_context_id,
persona_ref,
status: CommunityStatus::Pending { request_id },
favourite: false,
archived: false,
acknowledged: false,
member_since: None,
requested_at: Some(now),
receipt_at: None,
approved_at: None,
credential_delivery: None,
submit_transport: None,
request_id_confirmed: false,
relationships: Relationships::default(),
tasks: Tasks::default(),
vrcs_issued: Vrcs::default(),
vrcs_received: Vrcs::default(),
credentials: BTreeMap::new(),
member_vmc: None,
decision: None,
relationship_identifier_default: None,
retired_credentials: Vec::new(),
}
}
pub fn is_live(&self) -> bool {
self.status.requires_live_session()
}
pub fn activate(&mut self, now: DateTime<Utc>) {
if self.member_since.is_none() {
self.member_since = Some(now);
}
self.status = CommunityStatus::Active;
self.acknowledged = false;
}
pub fn reject(&mut self, evidence: DecisionEvidence) {
self.status = CommunityStatus::Rejected;
self.acknowledged = false;
self.decision = Some(evidence);
}
pub fn remove(&mut self, evidence: DecisionEvidence) {
self.status = CommunityStatus::Removed;
self.acknowledged = false;
self.decision = Some(evidence);
}
pub fn leave(&mut self) {
self.status = CommunityStatus::Left;
self.acknowledged = false;
}
pub fn withdraw(&mut self) -> bool {
if !matches!(self.status, CommunityStatus::Pending { .. }) {
return false;
}
self.status = CommunityStatus::Withdrawn;
self.acknowledged = false;
true
}
pub fn acknowledge(&mut self) {
self.acknowledged = true;
}
pub fn needs_attention(&self) -> bool {
match self.status {
CommunityStatus::Pending { .. } => true,
CommunityStatus::Rejected | CommunityStatus::Removed | CommunityStatus::Expired => {
!self.acknowledged
}
CommunityStatus::Active | CommunityStatus::Left | CommunityStatus::Withdrawn => false,
}
}
pub fn toggle_favourite(&mut self) -> bool {
self.favourite = !self.favourite;
self.favourite
}
pub fn can_archive_or_delete(&self) -> bool {
self.status.is_inactive()
}
pub fn expire_if_stale(&mut self, now: DateTime<Utc>) -> bool {
self.expire_if_stale_after(now, TimeDelta::days(PENDING_TIMEOUT_DAYS))
}
pub fn expire_if_stale_after(&mut self, now: DateTime<Utc>, timeout: TimeDelta) -> bool {
if matches!(self.status, CommunityStatus::Pending { .. })
&& let Some(requested) = self.requested_at
&& now - requested >= timeout
{
self.status = CommunityStatus::Expired;
self.acknowledged = false;
return true;
}
false
}
pub fn mark_acknowledged(&mut self, now: DateTime<Utc>) -> bool {
if self.receipt_at.is_none() {
self.receipt_at = Some(now);
return true;
}
false
}
pub fn confirm_request_id(&mut self, request_id: Uuid) -> bool {
let CommunityStatus::Pending { request_id: held } = &self.status else {
return false;
};
if *held == request_id && self.request_id_confirmed {
return false;
}
self.status = CommunityStatus::Pending { request_id };
self.request_id_confirmed = true;
true
}
pub fn is_pollable_pending(&self) -> bool {
matches!(self.status, CommunityStatus::Pending { .. })
}
pub fn mark_approved(&mut self, now: DateTime<Utc>) -> bool {
if self.approved_at.is_none() {
self.approved_at = Some(now);
return true;
}
false
}
pub fn approved_awaiting_credential(&self) -> bool {
matches!(self.status, CommunityStatus::Pending { .. })
&& self.approved_at.is_some()
&& !self
.credentials
.contains_key(&crate::CredentialKind::Membership)
}
pub fn joined_over_tsp(&self) -> bool {
self.submit_transport == Some(crate::didcomm::MessagingTransport::Tsp)
}
pub fn can_renew(&self) -> bool {
self.status.is_active() || self.approved_awaiting_credential()
}
pub fn wants_credential_resend(&self, now: DateTime<Utc>) -> bool {
if !self.approved_awaiting_credential() {
return false;
}
let grace = TimeDelta::seconds(CREDENTIAL_RESEND_GRACE_SECS);
if self.approved_at.is_none_or(|at| now - at < grace) {
return false;
}
let Some(delivery) = &self.credential_delivery else {
return false;
};
if delivery.delivered {
return false;
}
match delivery.last_answer {
None => true,
Some(CredentialResendAnswer::Queued { at }) => now - at >= grace,
Some(CredentialResendAnswer::RateLimited {
retry_after: Some(t),
}) => now >= t,
Some(
CredentialResendAnswer::RateLimited { retry_after: None }
| CredentialResendAnswer::NotNeeded,
) => false,
}
}
pub fn pending_unacknowledged(&self, now: DateTime<Utc>) -> bool {
matches!(self.status, CommunityStatus::Pending { .. })
&& self.receipt_at.is_none()
&& self
.requested_at
.is_some_and(|r| now - r >= TimeDelta::seconds(PENDING_ACK_GRACE_SECS))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingPoll {
pub vtc_did: VtcDid,
pub persona_ref: PersonaId,
pub request_id: Option<Uuid>,
pub submit_transport: Option<crate::didcomm::MessagingTransport>,
pub resend_credentials: bool,
}
pub const CREDENTIAL_RESEND_GRACE_SECS: i64 = 10 * 60;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialDelivery {
pub delivered: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_answer: Option<CredentialResendAnswer>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "answer", rename_all = "camelCase")]
pub enum CredentialResendAnswer {
Queued { at: DateTime<Utc> },
#[serde(rename_all = "camelCase")]
RateLimited { retry_after: Option<DateTime<Utc>> },
NotNeeded,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Account {
pub vta_did: String,
pub vta_url: String,
pub top_context_id: String,
#[serde(default)]
pub personas: HashMap<PersonaId, PersonaRecord>,
#[serde(default, deserialize_with = "de_communities")]
pub communities: HashMap<VtcDid, Vec<CommunityRecord>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
fn de_communities<'de, D>(d: D) -> Result<HashMap<VtcDid, Vec<CommunityRecord>>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
Many(Vec<CommunityRecord>),
One(Box<CommunityRecord>),
}
let raw: HashMap<VtcDid, OneOrMany> = HashMap::deserialize(d)?;
Ok(raw
.into_iter()
.map(|(k, v)| {
let list = match v {
OneOrMany::Many(list) => list,
OneOrMany::One(rec) => vec![*rec],
};
(k, list)
})
.collect())
}
impl Account {
pub fn memberships(&self) -> impl Iterator<Item = &CommunityRecord> {
self.communities.values().flatten()
}
pub fn memberships_mut(&mut self) -> impl Iterator<Item = &mut CommunityRecord> {
self.communities.values_mut().flatten()
}
pub fn memberships_for(&self, vtc: &str) -> &[CommunityRecord] {
self.communities.get(vtc).map(Vec::as_slice).unwrap_or(&[])
}
pub fn membership(&self, vtc: &str, persona: PersonaId) -> Option<&CommunityRecord> {
self.memberships_for(vtc)
.iter()
.find(|c| c.persona_ref == persona)
}
pub fn membership_mut(
&mut self,
vtc: &str,
persona: PersonaId,
) -> Option<&mut CommunityRecord> {
self.communities
.get_mut(vtc)?
.iter_mut()
.find(|c| c.persona_ref == persona)
}
pub fn membership_by_pending_request(
&mut self,
vtc: &str,
request_id: Uuid,
) -> Option<&mut CommunityRecord> {
self.communities.get_mut(vtc)?.iter_mut().find(
|c| matches!(&c.status, CommunityStatus::Pending { request_id: r } if *r == request_id),
)
}
pub fn membership_awaiting_request_id(
&mut self,
vtc: &str,
recipients: &[String],
) -> Option<&mut CommunityRecord> {
let unconfirmed: Vec<PersonaId> = self
.communities
.get(vtc)?
.iter()
.filter(|c| {
matches!(c.status, CommunityStatus::Pending { .. }) && !c.request_id_confirmed
})
.map(|c| c.persona_ref)
.collect();
let persona = match unconfirmed.as_slice() {
[only] => *only,
[] => return None,
several => {
let addressed: Vec<PersonaId> = several
.iter()
.copied()
.filter(|p| {
self.personas
.get(p)
.is_some_and(|rec| recipients.contains(&rec.did))
})
.collect();
let [only] = addressed.as_slice() else {
return None;
};
*only
}
};
self.membership_mut(vtc, persona)
}
pub fn pollable_pending(&self, now: DateTime<Utc>) -> Vec<PendingPoll> {
self.memberships()
.filter(|c| c.is_pollable_pending())
.filter_map(|c| {
let CommunityStatus::Pending { request_id } = c.status else {
return None;
};
Some(PendingPoll {
vtc_did: c.vtc_did.clone(),
persona_ref: c.persona_ref,
request_id: c.request_id_confirmed.then_some(request_id),
submit_transport: c.submit_transport,
resend_credentials: c.wants_credential_resend(now),
})
})
.collect()
}
pub fn add_membership(&mut self, record: CommunityRecord) {
self.communities
.entry(record.vtc_did.clone())
.or_default()
.push(record);
}
pub fn has_live_membership(&self, vtc: &str, persona: PersonaId) -> bool {
self.membership(vtc, persona).is_some_and(|c| c.is_live())
}
pub fn persona_id_for_did(&self, did: &str) -> Option<PersonaId> {
self.personas
.iter()
.find(|(_, p)| p.did == did)
.map(|(id, _)| *id)
}
pub fn membership_persona(&self, vtc: &str, persona: PersonaId) -> Option<&PersonaRecord> {
self.membership(vtc, persona)
.and_then(|c| self.personas.get(&c.persona_ref))
}
pub fn persona_referenced(&self, id: &PersonaId) -> bool {
self.memberships().any(|c| &c.persona_ref == id)
}
pub fn can_delete_persona(&self, id: &PersonaId) -> bool {
self.personas.contains_key(id) && !self.persona_referenced(id)
}
pub fn dangling_refs(&self) -> Vec<(&VtcDid, &PersonaId)> {
self.communities
.iter()
.flat_map(|(vtc, list)| list.iter().map(move |c| (vtc, c)))
.filter(|(_, c)| !self.personas.contains_key(&c.persona_ref))
.map(|(vtc, c)| (vtc, &c.persona_ref))
.collect()
}
pub fn active_communities(&self) -> impl Iterator<Item = &CommunityRecord> {
self.memberships().filter(|c| c.status.is_active())
}
pub fn expire_stale_pending(&mut self, now: DateTime<Utc>) -> Vec<(VtcDid, PersonaId)> {
self.expire_stale_pending_with(now, |_| TimeDelta::days(PENDING_TIMEOUT_DAYS))
}
pub fn expire_stale_pending_with(
&mut self,
now: DateTime<Utc>,
timeout_for: impl Fn(&CommunityRecord) -> TimeDelta,
) -> Vec<(VtcDid, PersonaId)> {
let mut expired = Vec::new();
for community in self.memberships_mut() {
let timeout = timeout_for(community);
if community.expire_if_stale_after(now, timeout) {
expired.push((community.vtc_did.clone(), community.persona_ref));
}
}
expired
}
pub fn actions_required_count(&self) -> usize {
self.memberships()
.filter(|c| !c.archived && c.needs_attention())
.count()
}
pub fn communities_for_display(&self, include_archived: bool) -> Vec<&CommunityRecord> {
let mut list: Vec<&CommunityRecord> = self
.memberships()
.filter(|c| include_archived || !c.archived)
.collect();
list.sort_by(|a, b| {
b.favourite
.cmp(&a.favourite)
.then_with(|| match (&a.display_name, &b.display_name) {
(Some(an), Some(bn)) => an.to_lowercase().cmp(&bn.to_lowercase()),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
})
.then_with(|| a.vtc_did.cmp(&b.vtc_did))
.then_with(|| a.persona_ref.cmp(&b.persona_ref))
});
list
}
pub fn default_working_membership(&self) -> Option<(VtcDid, PersonaId)> {
self.communities_for_display(false)
.into_iter()
.find(|c| c.status.is_active())
.map(|c| (c.vtc_did.clone(), c.persona_ref))
}
pub fn archive_membership(
&mut self,
vtc: &str,
persona: PersonaId,
) -> Result<(), OpenVTCError> {
let community = self
.membership_mut(vtc, persona)
.ok_or_else(|| OpenVTCError::Config(format!("Unknown membership: {vtc}")))?;
if !community.can_archive_or_delete() {
return Err(OpenVTCError::Config(format!(
"Cannot archive an active/pending community ({vtc}); leave it first"
)));
}
community.archived = true;
Ok(())
}
pub fn delete_membership(
&mut self,
vtc: &str,
persona: PersonaId,
) -> Result<CommunityRecord, OpenVTCError> {
let list = self
.communities
.get_mut(vtc)
.ok_or_else(|| OpenVTCError::Config(format!("Unknown membership: {vtc}")))?;
let idx = list
.iter()
.position(|c| c.persona_ref == persona)
.ok_or_else(|| OpenVTCError::Config(format!("Unknown membership: {vtc}")))?;
if !list[idx].can_archive_or_delete() {
return Err(OpenVTCError::Config(format!(
"Cannot delete an active/pending community ({vtc}); leave it first"
)));
}
let removed = list.remove(idx);
if list.is_empty() {
self.communities.remove(vtc);
}
Ok(removed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn persona(label: &str) -> PersonaRecord {
PersonaRecord {
extra: serde_json::Map::new(),
persona_id: PersonaId::new(),
did: format!("did:webvh:example.com:{label}"),
did_document: None,
key_refs: vec![KeyRef {
key_id: format!("key-{label}"),
purpose: KeyTypes::PersonaSigning,
created_at: Utc::now(),
}],
mediator_did: None,
origin_context_id: format!("openvtc/{label}"),
created_at: Utc::now(),
label: Some(label.to_string()),
}
}
#[test]
fn persona_did_document_without_also_known_as_still_loads() {
let legacy = serde_json::json!({
"persona_id": PersonaId::new().0,
"did": "did:webvh:example.com:dave",
"did_document": { "id": "did:webvh:example.com:dave" },
"key_refs": [],
"mediator_did": null,
"origin_context_id": "openvtc/dave",
"created_at": Utc::now(),
"label": "Dave",
});
let rec: PersonaRecord =
serde_json::from_value(legacy).expect("pre-0.4 persona config must still deserialize");
let doc = rec.did_document.expect("document should be present");
assert!(
doc.also_known_as.is_empty(),
"a document that claimed no names must load as claiming none, not fail"
);
}
fn community(vtc: &str, persona_ref: PersonaId, status: CommunityStatus) -> CommunityRecord {
CommunityRecord {
extra: serde_json::Map::new(),
vtc_did: vtc.to_string(),
display_name: Some(vtc.to_string()),
sub_context_id: format!("openvtc/{vtc}"),
submit_transport: None,
request_id_confirmed: false,
persona_ref,
status,
favourite: false,
archived: false,
acknowledged: false,
member_since: None,
requested_at: None,
receipt_at: None,
approved_at: None,
credential_delivery: None,
relationships: Relationships::default(),
tasks: Tasks::default(),
vrcs_issued: Vrcs::default(),
vrcs_received: Vrcs::default(),
credentials: BTreeMap::new(),
member_vmc: None,
decision: None,
relationship_identifier_default: None,
retired_credentials: Vec::new(),
}
}
#[test]
fn persona_id_for_did_maps_addressed_did_to_persona() {
let pa = persona("alice");
let pb = persona("bob");
let (pid_a, did_a) = (pa.persona_id, pa.did.clone());
let mut acct = Account::default();
acct.personas.insert(pa.persona_id, pa);
acct.personas.insert(pb.persona_id, pb);
assert_eq!(acct.persona_id_for_did(&did_a), Some(pid_a));
assert_eq!(acct.persona_id_for_did("did:web:nobody"), None);
}
#[test]
fn default_working_community_prefers_favourite_active_and_skips_inactive() {
let p = persona("p");
let mut acct = Account::default();
let vtc = |a: &Account| a.default_working_membership().map(|(v, _)| v);
assert_eq!(vtc(&acct), None);
let left = community("did:web:left", p.persona_id, CommunityStatus::Left);
acct.add_membership(left);
assert_eq!(vtc(&acct), None);
let act = community("did:web:active", p.persona_id, CommunityStatus::Active);
acct.add_membership(act);
assert_eq!(vtc(&acct).as_deref(), Some("did:web:active"));
let mut fav = community("did:web:fav", p.persona_id, CommunityStatus::Active);
fav.favourite = true;
acct.add_membership(fav);
acct.personas.insert(p.persona_id, p);
assert_eq!(vtc(&acct).as_deref(), Some("did:web:fav"));
}
#[test]
fn community_record_tasks_survive_json_round_trip() {
use crate::tasks::TaskType;
use std::sync::Arc;
let pid = PersonaId::new();
let mut comm = community("did:web:vtc-rt", pid, CommunityStatus::Active);
comm.tasks.new_task(
&Arc::new("task-rt".to_string()),
TaskType::RelationshipRequestRejected,
);
let json = serde_json::to_string(&comm).expect("serialize");
let back: CommunityRecord = serde_json::from_str(&json).expect("deserialize");
assert!(
back.tasks
.get_by_id(&Arc::new("task-rt".to_string()))
.is_some(),
"per-community task should survive the round trip"
);
}
#[test]
fn legacy_flat_credential_fields_load_into_registry() {
let mut vmc = crate::dtg::fixtures::grant("did:webvh:vtc.example", "did:webvh:m");
vmc["id"] = "vmc-1".into();
let legacy = serde_json::json!({
"vtc_did": "did:webvh:vtc.example",
"display_name": "Example VTC",
"sub_context_id": "openvtc/example",
"persona_ref": PersonaId::new().0,
"status": { "state": "active" },
"member_since": null,
"requested_at": null,
"membership_credential": vmc,
"role_credential": crate::dtg::fixtures::retired_role_endorsement(
"did:webvh:vtc.example", "did:webvh:m"
),
});
let rec: CommunityRecord = serde_json::from_value(legacy).unwrap();
assert_eq!(
rec.credentials
.get(&CredentialKind::Membership)
.and_then(|v| v.get("id"))
.and_then(|v| v.as_str()),
Some("vmc-1"),
);
assert!(!rec.credentials.contains_key(&CredentialKind::Role));
assert_eq!(rec.retired_credentials.len(), 1);
assert_eq!(rec.retired_credentials[0].kind, "Role");
}
#[test]
fn pre_v1_stored_credentials_are_set_aside_explicitly() {
let vtc = "did:webvh:vtc.example";
let mut old_vmc = crate::dtg::fixtures::grant(vtc, "did:webvh:m");
old_vmc["@context"][1] = crate::dtg::fixtures::RETIRED_CONTEXT.into();
old_vmc.as_object_mut().unwrap().remove("issuerScope");
let stored = serde_json::json!({
"vtc_did": vtc,
"display_name": null,
"sub_context_id": "openvtc/x",
"persona_ref": PersonaId::new().0,
"status": { "state": "active" },
"member_since": null,
"requested_at": null,
"credentials": {
"Membership": old_vmc,
"Role": crate::dtg::fixtures::retired_role_endorsement(vtc, "did:webvh:m"),
},
"member_vmc": { "type": ["VerifiableCredential", "MembershipCredential"] },
});
let mut rec: CommunityRecord = serde_json::from_value(stored).expect("loads");
assert!(rec.credentials.is_empty());
assert!(rec.member_vmc.is_none());
let kinds: Vec<&str> = rec
.retired_credentials
.iter()
.map(|r| r.kind.as_str())
.collect();
assert_eq!(
kinds,
[
"Membership",
"Role",
RetiredCredential::MEMBER_ACKNOWLEDGEMENT
]
);
assert!(rec.retired_credentials.iter().all(|r| !r.reason.is_empty()));
let saved = serde_json::to_value(&rec).unwrap();
let again: CommunityRecord = serde_json::from_value(saved).unwrap();
assert_eq!(again.retired_credentials, rec.retired_credentials);
rec.clear_retired("Role");
assert_eq!(rec.retired_credentials.len(), 2);
}
#[test]
fn typed_credentials_round_trip_and_tolerate_unknown() {
let mut rec = community(
"did:webvh:vtc.example",
PersonaId::new(),
CommunityStatus::Active,
);
let mut vmc = crate::dtg::fixtures::grant("did:webvh:vtc.example", "did:webvh:m");
vmc["id"] = "vmc-1".into();
rec.credentials.insert(CredentialKind::Membership, vmc);
let json = serde_json::to_value(&rec).unwrap();
assert!(
json["credentials"]["Membership"]["id"] == "vmc-1",
"credentials must persist under the config_key name: {json}",
);
let back: CommunityRecord = serde_json::from_value(json).unwrap();
assert_eq!(back.credentials, rec.credentials);
let with_unknown = serde_json::json!({
"vtc_did": "did:webvh:vtc.example",
"display_name": null,
"sub_context_id": "openvtc/x",
"persona_ref": PersonaId::new().0,
"status": { "state": "active" },
"member_since": null,
"requested_at": null,
"credentials": { "FutureKind": { "id": "x" } },
});
let rec: CommunityRecord = serde_json::from_value(with_unknown).unwrap();
assert!(rec.credentials.is_empty());
}
#[test]
fn new_pending_builds_a_live_pending_record() {
let now = Utc::now();
let pid = PersonaId::new();
let req = Uuid::new_v4();
let rec = CommunityRecord::new_pending(
"did:webvh:vtc.example".to_string(),
Some("Example VTC".to_string()),
"openvtc/example".to_string(),
pid,
req,
now,
);
assert!(matches!(rec.status, CommunityStatus::Pending { request_id } if request_id == req));
assert_eq!(rec.persona_ref, pid);
assert_eq!(rec.requested_at, Some(now));
assert_eq!(rec.member_since, None);
assert!(rec.is_live());
assert!(rec.needs_attention());
assert!(!rec.favourite && !rec.archived && !rec.acknowledged);
let mut acct = Account::default();
let pref = rec.persona_ref;
let vtc = rec.vtc_did.clone();
acct.add_membership(rec.clone());
assert!(acct.has_live_membership(&vtc, pref));
}
#[test]
fn status_classification() {
assert!(CommunityStatus::Active.is_active());
assert!(!CommunityStatus::Active.is_read_only());
assert!(!CommunityStatus::Active.needs_attention());
for s in [
CommunityStatus::Left,
CommunityStatus::Withdrawn,
CommunityStatus::Rejected,
CommunityStatus::Removed,
CommunityStatus::Expired,
] {
assert!(s.is_read_only(), "{s:?} should be read-only");
assert!(s.is_inactive(), "{s:?} should be inactive (archive/delete)");
}
let pending = CommunityStatus::Pending {
request_id: Uuid::new_v4(),
};
assert!(pending.is_read_only());
assert!(!pending.is_inactive(), "pending is in-flight, not inactive");
assert!(pending.needs_attention());
assert!(CommunityStatus::Rejected.needs_attention());
assert!(!CommunityStatus::Left.needs_attention());
assert!(!CommunityStatus::Withdrawn.needs_attention());
}
#[test]
fn persona_for_resolves_ref() {
let mut acct = Account::default();
let p = persona("alice");
let pid = p.persona_id;
acct.personas.insert(pid, p);
acct.add_membership(community("vtc:a", pid, CommunityStatus::Active));
let resolved = acct.membership_persona("vtc:a", pid).expect("resolves");
assert_eq!(resolved.persona_id, pid);
assert!(acct.membership_persona("vtc:missing", pid).is_none());
assert!(acct.dangling_refs().is_empty());
}
#[test]
fn referential_integrity_blocks_persona_delete() {
let mut acct = Account::default();
let p = persona("bob");
let pid = p.persona_id;
acct.personas.insert(pid, p);
assert!(acct.can_delete_persona(&pid));
acct.add_membership(community("vtc:b", pid, CommunityStatus::Active));
assert!(acct.persona_referenced(&pid));
assert!(!acct.can_delete_persona(&pid));
assert!(!acct.can_delete_persona(&PersonaId::new()));
}
#[test]
fn active_communities_filters() {
let mut acct = Account::default();
let p = persona("carol");
let pid = p.persona_id;
acct.personas.insert(pid, p);
acct.add_membership(community("a", pid, CommunityStatus::Active));
acct.add_membership(community("b", pid, CommunityStatus::Left));
acct.add_membership(community(
"c",
pid,
CommunityStatus::Pending {
request_id: Uuid::new_v4(),
},
));
assert_eq!(acct.active_communities().count(), 1);
}
#[test]
fn account_json_round_trip_preserves_shape() {
let mut acct = Account {
vta_did: "did:webvh:vta.example".into(),
vta_url: "https://vta.example".into(),
top_context_id: "openvtc".into(),
..Account::default()
};
let p = persona("dave");
let pid = p.persona_id;
let req = Uuid::new_v4();
acct.personas.insert(pid, p);
acct.add_membership(community(
"vtc:x",
pid,
CommunityStatus::Pending { request_id: req },
));
let json = serde_json::to_string(&acct).expect("serialize");
let back: Account = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.vta_did, acct.vta_did);
assert_eq!(back.top_context_id, "openvtc");
assert_eq!(back.personas.len(), 1);
let bp = back.personas.get(&pid).expect("persona survives");
assert_eq!(bp.did, "did:webvh:example.com:dave");
let bc = back.membership("vtc:x", pid).expect("community survives");
assert_eq!(bc.persona_ref, pid);
assert_eq!(bc.status, CommunityStatus::Pending { request_id: req });
}
#[test]
fn community_status_tag_is_stable() {
let j = serde_json::to_string(&CommunityStatus::Active).unwrap();
assert_eq!(j, r#"{"state":"active"}"#);
let j = serde_json::to_string(&CommunityStatus::Expired).unwrap();
assert_eq!(j, r#"{"state":"expired"}"#);
let j = serde_json::to_string(&CommunityStatus::Withdrawn).unwrap();
assert_eq!(j, r#"{"state":"withdrawn"}"#);
}
fn pending() -> CommunityStatus {
CommunityStatus::Pending {
request_id: Uuid::new_v4(),
}
}
#[test]
fn activate_stamps_member_since_once() {
let pid = PersonaId::new();
let mut c = community("v", pid, pending());
let t0 = Utc::now();
c.activate(t0);
assert_eq!(c.status, CommunityStatus::Active);
assert_eq!(c.member_since, Some(t0));
c.activate(t0 + TimeDelta::days(5));
assert_eq!(c.member_since, Some(t0), "member_since must not be reset");
}
#[test]
fn terminal_transitions_set_status() {
let pid = PersonaId::new();
let mut r = community("v", pid, pending());
r.reject(DecisionEvidence::default());
assert_eq!(r.status, CommunityStatus::Rejected);
let mut rm = community("v", pid, CommunityStatus::Active);
rm.remove(DecisionEvidence::default());
assert_eq!(rm.status, CommunityStatus::Removed);
let mut l = community("v", pid, CommunityStatus::Active);
l.leave();
assert_eq!(l.status, CommunityStatus::Left);
}
#[test]
fn withdraw_cancels_a_pending_join() {
let pid = PersonaId::new();
let mut p = community("v", pid, pending());
p.acknowledged = false;
assert!(p.withdraw(), "withdraw applies to a pending join");
assert_eq!(p.status, CommunityStatus::Withdrawn);
assert!(
p.can_archive_or_delete(),
"withdrawn is deletable/archivable"
);
assert!(!p.needs_attention(), "a withdrawn join must not nag");
assert!(!p.is_live(), "withdrawn needs no live session");
assert!(!p.withdraw(), "withdraw is a no-op once not pending");
let mut active = community("v", pid, CommunityStatus::Active);
assert!(
!active.withdraw(),
"withdraw must not touch an active membership"
);
assert_eq!(active.status, CommunityStatus::Active);
}
#[test]
fn expire_if_stale_only_fires_for_old_pending() {
let pid = PersonaId::new();
let now = Utc::now();
let mut fresh = community("v", pid, pending());
fresh.requested_at = Some(now - TimeDelta::days(PENDING_TIMEOUT_DAYS - 1));
assert!(!fresh.expire_if_stale(now));
assert!(matches!(fresh.status, CommunityStatus::Pending { .. }));
let mut stale = community("v", pid, pending());
stale.requested_at = Some(now - TimeDelta::days(PENDING_TIMEOUT_DAYS));
assert!(stale.expire_if_stale(now));
assert_eq!(stale.status, CommunityStatus::Expired);
let mut active = community("v", pid, CommunityStatus::Active);
active.requested_at = Some(now - TimeDelta::days(365));
assert!(!active.expire_if_stale(now));
assert_eq!(active.status, CommunityStatus::Active);
let mut no_ts = community("v", pid, pending());
no_ts.requested_at = None;
assert!(!no_ts.expire_if_stale(now));
}
#[test]
fn mark_acknowledged_stamps_once() {
let pid = PersonaId::new();
let now = Utc::now();
let mut c = community("v", pid, pending());
assert!(c.receipt_at.is_none());
assert!(c.mark_acknowledged(now));
assert_eq!(c.receipt_at, Some(now));
assert!(!c.mark_acknowledged(now + TimeDelta::seconds(10)));
assert_eq!(c.receipt_at, Some(now));
}
#[test]
fn credential_resend_is_asked_only_when_the_community_offers_it() {
let pid = PersonaId::new();
let now = Utc::now();
let grace = TimeDelta::seconds(CREDENTIAL_RESEND_GRACE_SECS);
let approved = |delivery: Option<CredentialDelivery>| {
let mut c = community("v", pid, pending());
c.mark_approved(now - grace);
c.credential_delivery = delivery;
c
};
let undelivered = |last_answer| {
Some(CredentialDelivery {
delivered: false,
last_answer,
})
};
assert!(!community("v", pid, pending()).wants_credential_resend(now));
assert!(!approved(None).wants_credential_resend(now));
assert!(approved(undelivered(None)).wants_credential_resend(now));
let mut early = approved(undelivered(None));
early.approved_at = Some(now);
assert!(!early.wants_credential_resend(now));
assert!(
!approved(Some(CredentialDelivery {
delivered: true,
last_answer: None
}))
.wants_credential_resend(now)
);
let queued = |at| undelivered(Some(CredentialResendAnswer::Queued { at }));
assert!(!approved(queued(now)).wants_credential_resend(now));
assert!(approved(queued(now - grace)).wants_credential_resend(now));
let limited =
|retry_after| undelivered(Some(CredentialResendAnswer::RateLimited { retry_after }));
assert!(!approved(limited(Some(now + grace))).wants_credential_resend(now));
assert!(approved(limited(Some(now))).wants_credential_resend(now));
assert!(
!approved(limited(None)).wants_credential_resend(now),
"limit spent"
);
assert!(
!approved(undelivered(Some(CredentialResendAnswer::NotNeeded)))
.wants_credential_resend(now)
);
let mut acct = Account::default();
acct.add_membership(approved(undelivered(None)));
assert!(acct.pollable_pending(now)[0].resend_credentials);
}
#[test]
fn a_join_submitted_over_tsp_is_spoken_to_over_tsp() {
use crate::didcomm::MessagingTransport;
let pid = PersonaId::new();
let mut c = community("v", pid, pending());
assert!(!c.joined_over_tsp(), "no transport recorded: DIDComm");
c.submit_transport = Some(MessagingTransport::DidComm);
assert!(!c.joined_over_tsp());
c.submit_transport = Some(MessagingTransport::Tsp);
assert!(c.joined_over_tsp());
}
#[test]
fn pending_unacknowledged_flags_only_unacked_pending_past_grace() {
let pid = PersonaId::new();
let now = Utc::now();
let grace = TimeDelta::seconds(PENDING_ACK_GRACE_SECS);
let mut fresh = community("v", pid, pending());
fresh.requested_at = Some(now - grace + TimeDelta::seconds(1));
assert!(!fresh.pending_unacknowledged(now));
let mut stuck = community("v", pid, pending());
stuck.requested_at = Some(now - grace);
assert!(stuck.pending_unacknowledged(now));
let mut acked = community("v", pid, pending());
acked.requested_at = Some(now - TimeDelta::days(1));
acked.mark_acknowledged(now - TimeDelta::days(1));
assert!(!acked.pending_unacknowledged(now));
let mut active = community("v", pid, CommunityStatus::Active);
active.requested_at = Some(now - TimeDelta::days(1));
assert!(!active.pending_unacknowledged(now));
}
#[test]
fn confirm_request_id_adopts_the_communitys_id_only_while_pending() {
let pid = PersonaId::new();
let real = Uuid::new_v4();
let mut c = community("v", pid, pending());
assert!(
c.is_pollable_pending(),
"a record still holding our submit id is exactly the one worth asking \
about — it asks id-less, and the answer carries the id"
);
assert!(
c.confirm_request_id(real),
"adopting is a change to persist"
);
assert!(matches!(c.status, CommunityStatus::Pending { request_id } if request_id == real));
assert!(c.is_pollable_pending());
assert!(
!c.confirm_request_id(real),
"re-confirming the same id changes nothing, so it must not force a save"
);
let mut rejected = community("v", pid, CommunityStatus::Rejected);
assert!(!rejected.confirm_request_id(real));
assert!(!rejected.is_pollable_pending());
}
#[test]
fn pollable_pending_covers_unconfirmed_joins_and_omits_their_id() {
let mut acct = Account::default();
let pid = PersonaId::new();
let real = Uuid::new_v4();
acct.add_membership(community("unconfirmed", pid, pending()));
acct.add_membership(community("active", pid, CommunityStatus::Active));
let mut confirmed = community("confirmed", pid, pending());
confirmed.confirm_request_id(real);
confirmed.submit_transport = Some(crate::didcomm::MessagingTransport::Tsp);
acct.add_membership(confirmed);
let pollable = acct.pollable_pending(Utc::now());
assert_eq!(
pollable.len(),
2,
"both pending joins are askable: {pollable:?}"
);
let unconfirmed = pollable
.iter()
.find(|p| p.vtc_did == "unconfirmed")
.expect("the unconfirmed join must be polled");
assert_eq!(
unconfirmed.request_id, None,
"our placeholder names a document the VTC has never heard of; omitting \
it is what makes the poll answerable"
);
let confirmed = pollable
.iter()
.find(|p| p.vtc_did == "confirmed")
.expect("the confirmed join is still polled");
assert_eq!(
confirmed.request_id,
Some(real),
"quote the community's own id"
);
assert_eq!(
confirmed.submit_transport,
Some(crate::didcomm::MessagingTransport::Tsp),
"the poll must take the transport the submit took"
);
}
#[test]
fn live_community_filters_inactive() {
let mut acct = Account::default();
let pid = PersonaId::new();
acct.add_membership(community("active", pid, CommunityStatus::Active));
acct.add_membership(community("left", pid, CommunityStatus::Left));
acct.add_membership(community("pend", pid, pending()));
assert!(acct.has_live_membership("active", pid));
assert!(acct.has_live_membership("pend", pid));
assert!(
!acct.has_live_membership("left", pid),
"Left is not a live membership"
);
assert!(!acct.has_live_membership("missing", pid));
}
#[test]
fn favourite_toggle_and_archive_delete_guard() {
let pid = PersonaId::new();
let mut c = community("v", pid, CommunityStatus::Active);
assert!(!c.favourite);
assert!(c.toggle_favourite());
assert!(c.favourite);
assert!(!c.toggle_favourite());
assert!(!community("v", pid, CommunityStatus::Active).can_archive_or_delete());
assert!(!community("v", pid, pending()).can_archive_or_delete());
for s in [
CommunityStatus::Left,
CommunityStatus::Rejected,
CommunityStatus::Removed,
CommunityStatus::Expired,
] {
assert!(community("v", pid, s).can_archive_or_delete());
}
}
#[test]
fn communities_for_display_orders_and_filters() {
let mut acct = Account::default();
let pid = PersonaId::new();
let mut zebra = community("did:z", pid, CommunityStatus::Active);
zebra.display_name = Some("Zebra".into());
let mut acme = community("did:a", pid, CommunityStatus::Active);
acme.display_name = Some("acme".into()); let mut fav = community("did:f", pid, CommunityStatus::Active);
fav.display_name = Some("Middle".into());
fav.favourite = true;
let mut archived = community("did:x", pid, CommunityStatus::Left);
archived.display_name = Some("Aardvark".into());
archived.archived = true;
for c in [zebra, acme, fav, archived] {
acct.add_membership(c);
}
let names: Vec<&str> = acct
.communities_for_display(false)
.iter()
.map(|c| c.display_name.as_deref().unwrap())
.collect();
assert_eq!(names, vec!["Middle", "acme", "Zebra"]);
let with_archived: Vec<&str> = acct
.communities_for_display(true)
.iter()
.map(|c| c.display_name.as_deref().unwrap())
.collect();
assert_eq!(with_archived, vec!["Middle", "Aardvark", "acme", "Zebra"]);
}
#[test]
fn archive_and_delete_respect_guards() {
let mut acct = Account::default();
let pid = PersonaId::new();
acct.add_membership(community("active", pid, CommunityStatus::Active));
acct.add_membership(community("left", pid, CommunityStatus::Left));
assert!(acct.archive_membership("active", pid).is_err());
assert!(acct.delete_membership("active", pid).is_err());
assert!(acct.archive_membership("missing", pid).is_err());
acct.archive_membership("left", pid).unwrap();
assert!(acct.membership("left", pid).unwrap().archived);
let removed = acct.delete_membership("left", pid).unwrap();
assert_eq!(removed.vtc_did, "left");
assert!(acct.membership("left", pid).is_none());
}
#[test]
fn a_published_decision_sla_replaces_the_client_timeout() {
let now = Utc::now();
let mut acct = Account::default();
let mut record = CommunityRecord::new_pending(
"did:webvh:vetted".into(),
None,
"top/vetted".into(),
PersonaId::new(),
Uuid::new_v4(),
now,
);
record.requested_at = Some(now - TimeDelta::days(PENDING_TIMEOUT_DAYS + 3));
acct.add_membership(record);
let expired = acct.expire_stale_pending_with(now, |c| {
if c.vtc_did == "did:webvh:vetted" {
TimeDelta::days(30)
} else {
TimeDelta::days(PENDING_TIMEOUT_DAYS)
}
});
assert!(expired.is_empty(), "the community said it may take 30 days");
assert!(
!acct.expire_stale_pending(now).is_empty(),
"the default would have expired it"
);
}
#[test]
fn expire_stale_pending_sweeps_and_reports() {
let mut acct = Account::default();
let pid = PersonaId::new();
let now = Utc::now();
let mut stale = community("stale", pid, pending());
stale.requested_at = Some(now - TimeDelta::days(10));
acct.add_membership(stale);
let mut fresh = community("fresh", pid, pending());
fresh.requested_at = Some(now - TimeDelta::days(1));
acct.add_membership(fresh);
acct.add_membership(community("active", pid, CommunityStatus::Active));
let expired = acct.expire_stale_pending(now);
assert_eq!(expired, vec![("stale".to_string(), pid)]);
assert_eq!(
acct.membership("stale", pid).unwrap().status,
CommunityStatus::Expired
);
assert!(matches!(
acct.membership("fresh", pid).unwrap().status,
CommunityStatus::Pending { .. }
));
assert_eq!(
acct.membership("active", pid).unwrap().status,
CommunityStatus::Active
);
}
#[test]
fn needs_attention_covers_pending_and_unacked_terminals() {
let pid = PersonaId::new();
assert!(community("v", pid, pending()).needs_attention());
assert!(!community("v", pid, CommunityStatus::Active).needs_attention());
assert!(
!community("v", pid, CommunityStatus::Left).needs_attention(),
"Left is voluntary — never an action"
);
for s in [
CommunityStatus::Rejected,
CommunityStatus::Removed,
CommunityStatus::Expired,
] {
let mut c = community("v", pid, s.clone());
assert!(c.needs_attention(), "{s:?} should nag until acknowledged");
c.acknowledge();
assert!(!c.needs_attention(), "{s:?} clears once acknowledged");
}
}
#[test]
fn fresh_terminal_outcome_resets_acknowledgement() {
let pid = PersonaId::new();
let mut c = community("v", pid, CommunityStatus::Rejected);
c.acknowledge();
assert!(!c.needs_attention());
c.activate(Utc::now());
assert!(!c.acknowledged, "returning to a live state clears the ack");
assert!(!c.needs_attention(), "Active never nags");
c.remove(DecisionEvidence::default());
assert!(
c.needs_attention(),
"a fresh Removed must nag despite the earlier ack"
);
}
#[test]
fn actions_required_count_excludes_acknowledged_and_archived() {
let mut acct = Account::default();
let pid = PersonaId::new();
acct.add_membership(community("pending", pid, pending()));
acct.add_membership(community("active", pid, CommunityStatus::Active));
let mut acked = community("acked", pid, CommunityStatus::Removed);
acked.acknowledge();
acct.add_membership(acked);
let mut archived = community("archived", pid, CommunityStatus::Expired);
archived.archived = true;
acct.add_membership(archived);
acct.add_membership(community("rejected", pid, CommunityStatus::Rejected));
assert_eq!(acct.actions_required_count(), 2);
acct.membership_mut("rejected", pid).unwrap().acknowledge();
assert_eq!(acct.actions_required_count(), 1);
}
#[test]
fn multiple_memberships_per_community_keyed_by_persona() {
let mut acct = Account::default();
let alice = PersonaId::new();
let bob = PersonaId::new();
let vtc = "did:web:acme";
acct.add_membership(community(vtc, alice, CommunityStatus::Active));
acct.add_membership(community(vtc, bob, pending()));
assert_eq!(acct.memberships_for(vtc).len(), 2);
assert_eq!(acct.memberships().count(), 2);
assert!(acct.membership(vtc, alice).is_some());
assert!(acct.membership(vtc, bob).is_some());
assert!(acct.has_live_membership(vtc, alice));
assert!(acct.has_live_membership(vtc, bob));
assert!(!acct.has_live_membership(vtc, PersonaId::new()));
acct.membership_mut(vtc, alice).unwrap().leave();
acct.delete_membership(vtc, alice).unwrap();
assert!(acct.membership(vtc, alice).is_none());
assert!(acct.membership(vtc, bob).is_some());
}
#[test]
fn legacy_single_record_config_loads_into_grouped_form() {
let pid = PersonaId::new();
let rec = community("did:web:acme", pid, CommunityStatus::Active);
let legacy = serde_json::json!({
"vta_did": "",
"vta_url": "",
"top_context_id": "",
"communities": { "did:web:acme": rec },
});
let acct: Account = serde_json::from_value(legacy).expect("legacy config loads");
assert_eq!(acct.memberships().count(), 1);
assert!(acct.membership("did:web:acme", pid).is_some());
let modern = serde_json::json!({
"vta_did": "",
"vta_url": "",
"top_context_id": "",
"communities": { "did:web:acme": [community("did:web:acme", pid, CommunityStatus::Active)] },
});
let acct: Account = serde_json::from_value(modern).expect("modern config loads");
assert_eq!(acct.memberships().count(), 1);
}
}
#[cfg(test)]
mod forward_compat_tests {
use super::*;
#[test]
fn unknown_persona_fields_survive_a_round_trip() {
let json = serde_json::json!({
"persona_id": Uuid::nil(),
"did": "did:webvh:Qm:example.com:alice",
"key_refs": [],
"origin_context_id": "top",
"created_at": "2026-08-20T00:00:00Z",
"label": "Alice",
"recovery_approver": "did:key:zApprover",
"future_nested": { "a": 1, "b": [true, null] }
});
let record: PersonaRecord = serde_json::from_value(json).expect("deserialize");
assert_eq!(record.label.as_deref(), Some("Alice"));
let back = serde_json::to_value(&record).expect("serialize");
assert_eq!(
back.get("recovery_approver").and_then(|v| v.as_str()),
Some("did:key:zApprover"),
"an unknown field was dropped: {back}"
);
assert_eq!(
back.get("future_nested"),
Some(&serde_json::json!({ "a": 1, "b": [true, null] })),
"nested structure was not preserved verbatim: {back}"
);
}
#[test]
fn unknown_membership_fields_survive_the_shadow() {
let json = serde_json::json!({
"vtc_did": "did:webvh:Qm:vtc.example.com:acme",
"display_name": "Acme",
"sub_context_id": "top/acme",
"persona_ref": Uuid::nil(),
"status": { "state": "active" },
"invented_by_a_newer_build": "keep me"
});
let record: CommunityRecord = serde_json::from_value(json).expect("deserialize");
let back = serde_json::to_value(&record).expect("serialize");
assert_eq!(
back.get("invented_by_a_newer_build")
.and_then(|v| v.as_str()),
Some("keep me"),
"the shadow dropped an unknown field: {back}"
);
}
#[test]
fn a_known_record_gains_nothing_on_the_wire() {
let (id, record) = {
let id = PersonaId(Uuid::nil());
(
id,
PersonaRecord {
persona_id: id,
did: "did:webvh:Qm:example.com:alice".to_string(),
did_document: None,
key_refs: Vec::new(),
mediator_did: None,
origin_context_id: "top".to_string(),
created_at: Utc::now(),
label: None,
extra: serde_json::Map::new(),
},
)
};
let _ = id;
let json = serde_json::to_value(&record).expect("serialize");
assert!(json.get("extra").is_none(), "{json}");
let obj = json.as_object().expect("object");
assert!(
!obj.keys().any(|k| k.starts_with('_')),
"unexpected synthetic key: {json}"
);
}
#[test]
fn unknown_account_fields_survive_a_round_trip() {
let json = serde_json::json!({
"vta_did": "did:webvh:Qm:vta.example.com:agent",
"vta_url": "",
"top_context_id": "top",
"recovery_approver_set": ["did:key:zA", "did:key:zB"]
});
let account: Account = serde_json::from_value(json).expect("deserialize");
let back = serde_json::to_value(&account).expect("serialize");
assert_eq!(
back.get("recovery_approver_set"),
Some(&serde_json::json!(["did:key:zA", "did:key:zB"])),
"{back}"
);
}
#[test]
fn a_legacy_org_did_still_loads_and_is_carried() {
let json = serde_json::json!({
"vta_did": "did:webvh:Qm:vta.example.com:agent",
"vta_url": "",
"top_context_id": "top",
"org_did": "did:webvh:QmOrg:org.example.com"
});
let account: Account = serde_json::from_value(json).expect("deserialize");
assert_eq!(account.top_context_id, "top");
let back = serde_json::to_value(&account).expect("serialize");
assert_eq!(
back.get("org_did"),
Some(&serde_json::json!("did:webvh:QmOrg:org.example.com")),
"{back}"
);
}
#[test]
fn a_new_account_writes_no_org_did() {
let back = serde_json::to_value(Account::default()).expect("serialize");
assert!(back.get("org_did").is_none(), "{back}");
}
}