use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebvhServerRecord {
pub id: String,
pub did: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebvhDidRecord {
pub did: String,
pub server_id: String,
pub mnemonic: String,
pub scid: String,
pub context_id: String,
pub portable: bool,
pub log_entry_count: u32,
#[serde(default)]
pub pre_rotation_count: u32,
#[serde(default = "default_next_fragment_id")]
pub next_fragment_id: u32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn default_next_fragment_id() -> u32 {
1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_record_round_trips_without_token_fields() {
let now = Utc::now();
let r = WebvhServerRecord {
id: "prod".into(),
did: "did:web:daemon.example".into(),
label: Some("prod hosting".into()),
created_at: now,
updated_at: now,
};
let json = serde_json::to_string(&r).unwrap();
assert!(
!json.contains("access_token"),
"access_token must not appear in serialised form: {json}"
);
assert!(
!json.contains("refresh_token"),
"refresh_token must not appear: {json}"
);
assert!(
!json.contains("access_expires_at"),
"access_expires_at must not appear: {json}"
);
}
#[test]
fn legacy_server_record_with_embedded_tokens_deserializes_cleanly() {
let json = r#"{
"id": "prod",
"did": "did:web:daemon.example",
"label": "prod",
"access_token": "leaky-access-token",
"access_expires_at": 9999999999,
"refresh_token": "leaky-refresh-token",
"created_at": "2026-05-01T00:00:00Z",
"updated_at": "2026-05-01T00:00:00Z"
}"#;
let r: WebvhServerRecord = serde_json::from_str(json).expect("must accept legacy fields");
assert_eq!(r.id, "prod");
assert_eq!(r.did, "did:web:daemon.example");
let reserialised = serde_json::to_string(&r).unwrap();
assert!(
!reserialised.contains("access_token"),
"legacy fields must be dropped on re-serialise: {reserialised}"
);
assert!(
!reserialised.contains("refresh_token"),
"legacy fields must be dropped: {reserialised}"
);
}
#[test]
fn legacy_record_loads_with_default_pre_rotation_and_fragment_id() {
let json = r#"{
"did": "did:webvh:Q...:vta.example.com:primary",
"server_id": "serverless",
"mnemonic": "",
"scid": "Q...",
"context_id": "primary",
"portable": false,
"log_entry_count": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}"#;
let r: WebvhDidRecord = serde_json::from_str(json).unwrap();
assert_eq!(r.pre_rotation_count, 0);
assert_eq!(r.next_fragment_id, 1);
}
#[test]
fn record_with_new_fields_round_trips() {
let r = WebvhDidRecord {
did: "did:webvh:abc:vta.example.com:primary".into(),
server_id: "serverless".into(),
mnemonic: "".into(),
scid: "abc".into(),
context_id: "primary".into(),
portable: false,
log_entry_count: 3,
pre_rotation_count: 2,
next_fragment_id: 5,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let json = serde_json::to_string(&r).unwrap();
let restored: WebvhDidRecord = serde_json::from_str(&json).unwrap();
assert_eq!(restored.pre_rotation_count, 2);
assert_eq!(restored.next_fragment_id, 5);
assert_eq!(restored.log_entry_count, 3);
}
}