1use jiff::Timestamp;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use crate::{BackupCredentials, Outcome, Redacted};
14
15#[derive(Debug, Clone, Serialize)]
20pub struct RestoreCapabilitiesRequest<'a> {
21 pub intents: &'a [&'a str],
22}
23
24#[derive(Debug, Clone, Deserialize)]
30pub struct WorklistEntry {
31 pub replica_id: Uuid,
32 pub group_id: Uuid,
33 pub server_id: Uuid,
34 pub r#type: String,
35 pub intent: String,
36 pub name: String,
38 pub freshness_seconds: Option<i64>,
40 pub snapshot_id: Option<String>,
42 pub snapshot_at: Option<String>,
44 pub storage: String,
46 pub bucket: String,
47 pub prefix: String,
48 pub region: String,
49}
50
51#[derive(Debug, Clone, Serialize)]
53pub struct RestoreCredentialsRequest<'a> {
54 pub group: Uuid,
55 pub r#type: &'a str,
56}
57
58#[derive(Debug, Clone, Deserialize)]
63pub struct RestoreCredentials {
64 pub credentials: BackupCredentials,
65 pub repo_password: Redacted<String>,
66}
67
68#[derive(Debug, Clone, Serialize)]
75pub struct RestoreVerification<'a> {
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub replica_id: Option<Uuid>,
78 pub group: Uuid,
79 pub server_id: Uuid,
80 pub r#type: &'a str,
81 pub intent: &'a str,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub snapshot_id: Option<&'a str>,
84 pub outcome: Outcome,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub error: Option<&'a str>,
87 pub replica_healthy: bool,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub postgres_version: Option<&'a str>,
90 pub observed_at: Timestamp,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub s3_sent_raw_bytes: Option<i64>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub s3_sent_payload_bytes: Option<i64>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub s3_received_raw_bytes: Option<i64>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub s3_received_payload_bytes: Option<i64>,
99}
100
101#[cfg(test)]
102mod tests {
103 use serde_json::json;
104
105 use super::*;
106
107 #[test]
108 fn capabilities_request_lists_intents() {
109 let intents = ["verify", "analytics", "disaster-recovery"];
110 let req = RestoreCapabilitiesRequest { intents: &intents };
111 assert_eq!(
112 serde_json::to_value(&req).unwrap(),
113 json!({"intents": ["verify", "analytics", "disaster-recovery"]})
114 );
115 }
116
117 #[test]
118 fn credentials_request_carries_group_and_type() {
119 let group = "11111111-1111-1111-1111-111111111111".parse().unwrap();
120 let req = RestoreCredentialsRequest {
121 group,
122 r#type: "tamanu-postgres",
123 };
124 assert_eq!(
125 serde_json::to_value(&req).unwrap(),
126 json!({
127 "group": "11111111-1111-1111-1111-111111111111",
128 "type": "tamanu-postgres",
129 })
130 );
131 }
132
133 #[test]
134 fn worklist_entry_deserialises() {
135 let body = json!({
136 "replica_id": "11111111-1111-1111-1111-111111111111",
137 "group_id": "22222222-2222-2222-2222-222222222222",
138 "server_id": "33333333-3333-3333-3333-333333333333",
139 "type": "tamanu-postgres",
140 "intent": "verify",
141 "name": "nightly verify",
142 "freshness_seconds": 86400,
143 "snapshot_id": "kopia-snap",
144 "snapshot_at": "2026-06-30T00:00:00Z",
145 "storage": "s3",
146 "bucket": "my-bucket",
147 "prefix": "",
148 "region": "ap-southeast-2",
149 });
150 let entry: WorklistEntry = serde_json::from_value(body).unwrap();
151 assert_eq!(entry.r#type, "tamanu-postgres");
152 assert_eq!(entry.intent, "verify");
153 assert_eq!(entry.name, "nightly verify");
154 assert_eq!(entry.freshness_seconds, Some(86400));
155 assert_eq!(entry.snapshot_id.as_deref(), Some("kopia-snap"));
156 assert_eq!(entry.bucket, "my-bucket");
157 }
158
159 #[test]
160 fn worklist_entry_allows_null_snapshot_and_freshness() {
161 let body = json!({
162 "replica_id": "11111111-1111-1111-1111-111111111111",
163 "group_id": "22222222-2222-2222-2222-222222222222",
164 "server_id": "33333333-3333-3333-3333-333333333333",
165 "type": "tamanu-postgres",
166 "intent": "verify",
167 "name": "latest",
168 "freshness_seconds": null,
169 "snapshot_id": null,
170 "snapshot_at": null,
171 "storage": "s3",
172 "bucket": "my-bucket",
173 "prefix": "",
174 "region": "ap-southeast-2",
175 });
176 let entry: WorklistEntry = serde_json::from_value(body).unwrap();
177 assert_eq!(entry.freshness_seconds, None);
178 assert_eq!(entry.snapshot_id, None);
179 assert_eq!(entry.snapshot_at, None);
180 }
181
182 #[test]
183 fn restore_credentials_deserialise_composite() {
184 let body = json!({
185 "credentials": {
186 "Version": 1,
187 "AccessKeyId": "AKIA",
188 "SecretAccessKey": "secret",
189 "SessionToken": "token",
190 "Expiration": "2026-05-21T13:00:00Z",
191 },
192 "repo_password": "hunter2",
193 });
194 let creds: RestoreCredentials = serde_json::from_value(body).unwrap();
195 assert_eq!(creds.credentials.access_key_id, "AKIA");
196 assert_eq!(&*creds.repo_password, "hunter2");
197 }
198
199 #[test]
200 fn restore_credentials_debug_does_not_leak_password() {
201 let body = json!({
202 "credentials": {
203 "Version": 1,
204 "AccessKeyId": "AKIA",
205 "SecretAccessKey": "secret",
206 "SessionToken": "token",
207 "Expiration": "2026-05-21T13:00:00Z",
208 },
209 "repo_password": "super-secret-repo-pw",
210 });
211 let creds: RestoreCredentials = serde_json::from_value(body).unwrap();
212 let debug = format!("{creds:?}");
213 assert!(!debug.contains("super-secret-repo-pw"));
214 assert!(debug.contains("<redacted>"));
215 }
216
217 #[test]
218 fn verification_omits_optional_fields() {
219 let report = RestoreVerification {
220 replica_id: None,
221 group: "22222222-2222-2222-2222-222222222222".parse().unwrap(),
222 server_id: "33333333-3333-3333-3333-333333333333".parse().unwrap(),
223 r#type: "tamanu-postgres",
224 intent: "verify",
225 snapshot_id: None,
226 outcome: Outcome::Failure,
227 error: None,
228 replica_healthy: false,
229 postgres_version: None,
230 observed_at: "2026-06-30T00:00:00Z".parse().unwrap(),
231 s3_sent_raw_bytes: None,
232 s3_sent_payload_bytes: None,
233 s3_received_raw_bytes: None,
234 s3_received_payload_bytes: None,
235 };
236 assert_eq!(
237 serde_json::to_value(&report).unwrap(),
238 json!({
239 "group": "22222222-2222-2222-2222-222222222222",
240 "server_id": "33333333-3333-3333-3333-333333333333",
241 "type": "tamanu-postgres",
242 "intent": "verify",
243 "outcome": "failure",
244 "replica_healthy": false,
245 "observed_at": "2026-06-30T00:00:00Z",
246 })
247 );
248 }
249
250 #[test]
251 fn verification_includes_full_payload() {
252 let report = RestoreVerification {
253 replica_id: Some("11111111-1111-1111-1111-111111111111".parse().unwrap()),
254 group: "22222222-2222-2222-2222-222222222222".parse().unwrap(),
255 server_id: "33333333-3333-3333-3333-333333333333".parse().unwrap(),
256 r#type: "tamanu-postgres",
257 intent: "verify",
258 snapshot_id: Some("kopia-snap"),
259 outcome: Outcome::Success,
260 error: None,
261 replica_healthy: true,
262 postgres_version: Some("15"),
263 observed_at: "2026-06-30T00:00:00Z".parse().unwrap(),
264 s3_sent_raw_bytes: Some(123),
265 s3_sent_payload_bytes: Some(120),
266 s3_received_raw_bytes: Some(456),
267 s3_received_payload_bytes: Some(450),
268 };
269 assert_eq!(
270 serde_json::to_value(&report).unwrap(),
271 json!({
272 "replica_id": "11111111-1111-1111-1111-111111111111",
273 "group": "22222222-2222-2222-2222-222222222222",
274 "server_id": "33333333-3333-3333-3333-333333333333",
275 "type": "tamanu-postgres",
276 "intent": "verify",
277 "snapshot_id": "kopia-snap",
278 "outcome": "success",
279 "replica_healthy": true,
280 "postgres_version": "15",
281 "observed_at": "2026-06-30T00:00:00Z",
282 "s3_sent_raw_bytes": 123,
283 "s3_sent_payload_bytes": 120,
284 "s3_received_raw_bytes": 456,
285 "s3_received_payload_bytes": 450,
286 })
287 );
288 }
289}