use serde::{Deserialize, Serialize};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
pub const KEYSPACE_NAME: &str = "service_prev_config";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ServiceKind {
Rest,
Didcomm,
Webauthn,
}
impl ServiceKind {
pub(crate) fn storage_key(self) -> &'static str {
match self {
ServiceKind::Rest => "rest",
ServiceKind::Didcomm => "didcomm",
ServiceKind::Webauthn => "webauthn",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum ServiceConfigSnapshot {
Rest(RestSnapshot),
Didcomm(DidcommSnapshot),
Webauthn(WebauthnSnapshot),
}
impl ServiceConfigSnapshot {
pub fn kind(&self) -> ServiceKind {
match self {
ServiceConfigSnapshot::Rest(_) => ServiceKind::Rest,
ServiceConfigSnapshot::Didcomm(_) => ServiceKind::Didcomm,
ServiceConfigSnapshot::Webauthn(_) => ServiceKind::Webauthn,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "lowercase")]
pub enum RestSnapshot {
Enabled { url: String },
Disabled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "lowercase")]
pub enum DidcommSnapshot {
Enabled {
mediator_did: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
routing_keys: Vec<String>,
},
Disabled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "lowercase")]
pub enum WebauthnSnapshot {
Enabled { url: String },
Disabled,
}
pub async fn read(
ks: &KeyspaceHandle,
kind: ServiceKind,
) -> Result<Option<ServiceConfigSnapshot>, AppError> {
let key = kind.storage_key().as_bytes().to_vec();
let snap: Option<ServiceConfigSnapshot> = ks.get(key).await?;
if let Some(s) = &snap {
let stored_kind = s.kind();
if stored_kind != kind {
return Err(AppError::Internal(format!(
"snapshot kind mismatch under key {key:?}: stored {stored_kind:?}, \
requested {kind:?}",
key = kind.storage_key(),
)));
}
}
Ok(snap)
}
pub async fn write(ks: &KeyspaceHandle, snapshot: ServiceConfigSnapshot) -> Result<(), AppError> {
let key = snapshot.kind().storage_key().as_bytes().to_vec();
ks.insert(key, &snapshot).await
}
pub async fn clear(ks: &KeyspaceHandle, kind: ServiceKind) -> Result<(), AppError> {
let key = kind.storage_key().as_bytes().to_vec();
ks.remove(key).await
}
#[cfg(test)]
mod tests {
use super::*;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
async fn empty_snapshot_keyspace() -> (tempfile::TempDir, KeyspaceHandle) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().into(),
})
.expect("open store");
let ks = store.keyspace(KEYSPACE_NAME).expect("keyspace");
(dir, ks)
}
#[tokio::test]
async fn read_returns_none_when_no_snapshot_present() {
let (_dir, ks) = empty_snapshot_keyspace().await;
assert!(read(&ks, ServiceKind::Rest).await.unwrap().is_none());
assert!(read(&ks, ServiceKind::Didcomm).await.unwrap().is_none());
}
#[tokio::test]
async fn write_then_read_round_trips_rest_enabled() {
let (_dir, ks) = empty_snapshot_keyspace().await;
let snap = ServiceConfigSnapshot::Rest(RestSnapshot::Enabled {
url: "https://vta.example.com".into(),
});
write(&ks, snap.clone()).await.unwrap();
let restored = read(&ks, ServiceKind::Rest).await.unwrap().unwrap();
assert_eq!(restored, snap);
}
#[tokio::test]
async fn write_then_read_round_trips_rest_disabled() {
let (_dir, ks) = empty_snapshot_keyspace().await;
let snap = ServiceConfigSnapshot::Rest(RestSnapshot::Disabled);
write(&ks, snap.clone()).await.unwrap();
let restored = read(&ks, ServiceKind::Rest).await.unwrap().unwrap();
assert_eq!(restored, snap);
}
#[tokio::test]
async fn write_then_read_round_trips_didcomm_enabled_with_routing_keys() {
let (_dir, ks) = empty_snapshot_keyspace().await;
let snap = ServiceConfigSnapshot::Didcomm(DidcommSnapshot::Enabled {
mediator_did: "did:peer:2.Vz...mediator".into(),
routing_keys: vec!["did:peer:2.Vz...key1".into(), "did:peer:2.Vz...key2".into()],
});
write(&ks, snap.clone()).await.unwrap();
let restored = read(&ks, ServiceKind::Didcomm).await.unwrap().unwrap();
assert_eq!(restored, snap);
}
#[tokio::test]
async fn write_then_read_round_trips_didcomm_enabled_no_routing_keys() {
let (_dir, ks) = empty_snapshot_keyspace().await;
let snap = ServiceConfigSnapshot::Didcomm(DidcommSnapshot::Enabled {
mediator_did: "did:peer:2.Vz...mediator".into(),
routing_keys: vec![],
});
write(&ks, snap.clone()).await.unwrap();
let restored = read(&ks, ServiceKind::Didcomm).await.unwrap().unwrap();
assert_eq!(restored, snap);
}
#[tokio::test]
async fn write_then_read_round_trips_didcomm_disabled() {
let (_dir, ks) = empty_snapshot_keyspace().await;
let snap = ServiceConfigSnapshot::Didcomm(DidcommSnapshot::Disabled);
write(&ks, snap.clone()).await.unwrap();
let restored = read(&ks, ServiceKind::Didcomm).await.unwrap().unwrap();
assert_eq!(restored, snap);
}
#[tokio::test]
async fn second_write_overwrites_first_for_same_kind() {
let (_dir, ks) = empty_snapshot_keyspace().await;
let first = ServiceConfigSnapshot::Rest(RestSnapshot::Enabled {
url: "https://old.example.com".into(),
});
write(&ks, first).await.unwrap();
let second = ServiceConfigSnapshot::Rest(RestSnapshot::Enabled {
url: "https://new.example.com".into(),
});
write(&ks, second.clone()).await.unwrap();
let restored = read(&ks, ServiceKind::Rest).await.unwrap().unwrap();
assert_eq!(restored, second);
}
#[tokio::test]
async fn clear_removes_snapshot() {
let (_dir, ks) = empty_snapshot_keyspace().await;
write(
&ks,
ServiceConfigSnapshot::Rest(RestSnapshot::Enabled {
url: "https://vta.example.com".into(),
}),
)
.await
.unwrap();
clear(&ks, ServiceKind::Rest).await.unwrap();
assert!(read(&ks, ServiceKind::Rest).await.unwrap().is_none());
}
#[tokio::test]
async fn clear_is_a_noop_when_nothing_is_present() {
let (_dir, ks) = empty_snapshot_keyspace().await;
clear(&ks, ServiceKind::Rest).await.unwrap();
clear(&ks, ServiceKind::Didcomm).await.unwrap();
}
#[tokio::test]
async fn rest_and_didcomm_snapshots_are_independent() {
let (_dir, ks) = empty_snapshot_keyspace().await;
let rest = ServiceConfigSnapshot::Rest(RestSnapshot::Enabled {
url: "https://vta.example.com".into(),
});
let didcomm = ServiceConfigSnapshot::Didcomm(DidcommSnapshot::Enabled {
mediator_did: "did:peer:2.Vz...mediator".into(),
routing_keys: vec![],
});
write(&ks, rest.clone()).await.unwrap();
write(&ks, didcomm.clone()).await.unwrap();
assert_eq!(read(&ks, ServiceKind::Rest).await.unwrap().unwrap(), rest,);
assert_eq!(
read(&ks, ServiceKind::Didcomm).await.unwrap().unwrap(),
didcomm,
);
clear(&ks, ServiceKind::Rest).await.unwrap();
assert!(read(&ks, ServiceKind::Rest).await.unwrap().is_none());
assert_eq!(
read(&ks, ServiceKind::Didcomm).await.unwrap().unwrap(),
didcomm,
);
}
#[test]
fn snapshot_wire_form_is_stable() {
let snap = ServiceConfigSnapshot::Rest(RestSnapshot::Enabled {
url: "https://vta.example.com".into(),
});
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(json["kind"], "rest");
assert_eq!(json["state"], "enabled");
assert_eq!(json["url"], "https://vta.example.com");
let snap = ServiceConfigSnapshot::Didcomm(DidcommSnapshot::Enabled {
mediator_did: "did:peer:2.M".into(),
routing_keys: vec!["did:peer:2.K".into()],
});
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(json["kind"], "didcomm");
assert_eq!(json["state"], "enabled");
assert_eq!(json["mediator_did"], "did:peer:2.M");
assert_eq!(json["routing_keys"][0], "did:peer:2.K");
let snap = ServiceConfigSnapshot::Didcomm(DidcommSnapshot::Disabled);
let json = serde_json::to_value(&snap).unwrap();
assert_eq!(json["kind"], "didcomm");
assert_eq!(json["state"], "disabled");
}
#[tokio::test]
async fn read_rejects_kind_mismatch() {
let (_dir, ks) = empty_snapshot_keyspace().await;
let didcomm_payload = ServiceConfigSnapshot::Didcomm(DidcommSnapshot::Disabled);
let bytes = serde_json::to_vec(&didcomm_payload).unwrap();
ks.insert_raw(ServiceKind::Rest.storage_key().as_bytes().to_vec(), bytes)
.await
.unwrap();
let err = read(&ks, ServiceKind::Rest).await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("kind mismatch"),
"expected kind-mismatch error, got: {msg}",
);
}
}