1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
25#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
26pub struct WebvhServerRecord {
27 pub id: String,
28 pub did: String,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub label: Option<String>,
31 pub created_at: DateTime<Utc>,
32 pub updated_at: DateTime<Utc>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
37pub struct WebvhDidRecord {
38 pub did: String,
39 pub server_id: String,
40 pub mnemonic: String,
41 pub scid: String,
42 pub context_id: String,
43 pub portable: bool,
44 pub log_entry_count: u32,
45 #[serde(default)]
51 pub pre_rotation_count: u32,
52 #[serde(default = "default_next_fragment_id")]
58 pub next_fragment_id: u32,
59 pub created_at: DateTime<Utc>,
60 pub updated_at: DateTime<Utc>,
61}
62
63fn default_next_fragment_id() -> u32 {
64 1
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70
71 #[test]
76 fn server_record_round_trips_without_token_fields() {
77 let now = Utc::now();
78 let r = WebvhServerRecord {
79 id: "prod".into(),
80 did: "did:web:daemon.example".into(),
81 label: Some("prod hosting".into()),
82 created_at: now,
83 updated_at: now,
84 };
85 let json = serde_json::to_string(&r).unwrap();
86 assert!(
87 !json.contains("access_token"),
88 "access_token must not appear in serialised form: {json}"
89 );
90 assert!(
91 !json.contains("refresh_token"),
92 "refresh_token must not appear: {json}"
93 );
94 assert!(
95 !json.contains("access_expires_at"),
96 "access_expires_at must not appear: {json}"
97 );
98 }
99
100 #[test]
106 fn legacy_server_record_with_embedded_tokens_deserializes_cleanly() {
107 let json = r#"{
108 "id": "prod",
109 "did": "did:web:daemon.example",
110 "label": "prod",
111 "access_token": "leaky-access-token",
112 "access_expires_at": 9999999999,
113 "refresh_token": "leaky-refresh-token",
114 "created_at": "2026-05-01T00:00:00Z",
115 "updated_at": "2026-05-01T00:00:00Z"
116 }"#;
117 let r: WebvhServerRecord = serde_json::from_str(json).expect("must accept legacy fields");
118 assert_eq!(r.id, "prod");
119 assert_eq!(r.did, "did:web:daemon.example");
120 let reserialised = serde_json::to_string(&r).unwrap();
122 assert!(
123 !reserialised.contains("access_token"),
124 "legacy fields must be dropped on re-serialise: {reserialised}"
125 );
126 assert!(
127 !reserialised.contains("refresh_token"),
128 "legacy fields must be dropped: {reserialised}"
129 );
130 }
131
132 #[test]
133 fn legacy_record_loads_with_default_pre_rotation_and_fragment_id() {
134 let json = r#"{
139 "did": "did:webvh:Q...:vta.example.com:primary",
140 "server_id": "serverless",
141 "mnemonic": "",
142 "scid": "Q...",
143 "context_id": "primary",
144 "portable": false,
145 "log_entry_count": 1,
146 "created_at": "2026-01-01T00:00:00Z",
147 "updated_at": "2026-01-01T00:00:00Z"
148 }"#;
149 let r: WebvhDidRecord = serde_json::from_str(json).unwrap();
150 assert_eq!(r.pre_rotation_count, 0);
151 assert_eq!(r.next_fragment_id, 1);
152 }
153
154 #[test]
155 fn record_with_new_fields_round_trips() {
156 let r = WebvhDidRecord {
157 did: "did:webvh:abc:vta.example.com:primary".into(),
158 server_id: "serverless".into(),
159 mnemonic: "".into(),
160 scid: "abc".into(),
161 context_id: "primary".into(),
162 portable: false,
163 log_entry_count: 3,
164 pre_rotation_count: 2,
165 next_fragment_id: 5,
166 created_at: Utc::now(),
167 updated_at: Utc::now(),
168 };
169 let json = serde_json::to_string(&r).unwrap();
170 let restored: WebvhDidRecord = serde_json::from_str(&json).unwrap();
171 assert_eq!(restored.pre_rotation_count, 2);
172 assert_eq!(restored.next_fragment_id, 5);
173 assert_eq!(restored.log_entry_count, 3);
174 }
175}