1use jiff::Timestamp;
10use serde::{Deserialize, Serialize};
11
12use crate::Redacted;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum Purpose {
21 #[default]
22 Backup,
23 Restore,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum Outcome {
30 Success,
31 Failure,
32}
33
34#[derive(Debug, Clone, Serialize)]
36pub struct CapabilitiesRequest<'a> {
37 pub types: &'a [String],
38}
39
40#[derive(Debug, Clone, Serialize)]
42pub struct BackupCredentialsRequest<'a> {
43 pub r#type: &'a str,
44 pub purpose: Purpose,
45}
46
47#[derive(Debug, Clone, Deserialize)]
52#[serde(rename_all = "PascalCase")]
53pub struct BackupCredentials {
54 pub version: u32,
55 pub access_key_id: String,
56 pub secret_access_key: Redacted<String>,
57 pub session_token: Redacted<String>,
58 pub expiration: Timestamp,
59}
60
61#[derive(Debug, Clone, Serialize)]
64#[serde(rename_all = "PascalCase")]
65pub struct ContainerCreds {
66 pub access_key_id: String,
67 pub secret_access_key: Redacted<String>,
68 pub token: Redacted<String>,
69 pub expiration: Timestamp,
70}
71
72impl From<&BackupCredentials> for ContainerCreds {
73 fn from(c: &BackupCredentials) -> Self {
74 Self {
75 access_key_id: c.access_key_id.clone(),
76 secret_access_key: c.secret_access_key.clone(),
77 token: c.session_token.clone(),
78 expiration: c.expiration,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Deserialize)]
85pub struct BackupTarget {
86 pub storage: String,
88 pub bucket: String,
89 #[serde(default)]
91 pub prefix: String,
92 pub region: String,
93 pub repo_password: Redacted<String>,
94}
95
96#[derive(Debug, Clone)]
99pub enum TargetOutcome {
100 Ready(BackupTarget),
101 Dormant,
102}
103
104#[derive(Debug, Clone, Serialize)]
106pub struct BackupReport<'a> {
107 pub run_id: &'a str,
109 pub r#type: &'a str,
110 pub purpose: Purpose,
111 pub outcome: Outcome,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub error: Option<&'a str>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub bytes_uploaded: Option<i64>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub snapshot_id: Option<&'a str>,
118 #[serde(skip_serializing_if = "Option::is_none")]
123 pub s3_sent_raw_bytes: Option<i64>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub s3_sent_payload_bytes: Option<i64>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub s3_received_raw_bytes: Option<i64>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub s3_received_payload_bytes: Option<i64>,
130}
131
132#[cfg(test)]
133mod tests {
134 use serde_json::json;
135
136 use super::*;
137
138 #[test]
139 fn purpose_serialises_lowercase() {
140 assert_eq!(
141 serde_json::to_value(Purpose::Backup).unwrap(),
142 json!("backup")
143 );
144 assert_eq!(
145 serde_json::to_value(Purpose::Restore).unwrap(),
146 json!("restore")
147 );
148 }
149
150 #[test]
151 fn purpose_defaults_to_backup() {
152 assert_eq!(Purpose::default(), Purpose::Backup);
153 }
154
155 #[test]
156 fn outcome_serialises_lowercase() {
157 assert_eq!(
158 serde_json::to_value(Outcome::Success).unwrap(),
159 json!("success")
160 );
161 assert_eq!(
162 serde_json::to_value(Outcome::Failure).unwrap(),
163 json!("failure")
164 );
165 }
166
167 #[test]
168 fn credentials_request_carries_type_and_purpose() {
169 let req = BackupCredentialsRequest {
170 r#type: "tamanu-postgres",
171 purpose: Purpose::Backup,
172 };
173 assert_eq!(
174 serde_json::to_value(&req).unwrap(),
175 json!({"type": "tamanu-postgres", "purpose": "backup"})
176 );
177 }
178
179 #[test]
180 fn capabilities_request_lists_types() {
181 let types = vec!["tamanu-postgres".to_owned(), "files".to_owned()];
182 let req = CapabilitiesRequest { types: &types };
183 assert_eq!(
184 serde_json::to_value(&req).unwrap(),
185 json!({"types": ["tamanu-postgres", "files"]})
186 );
187 }
188
189 #[test]
190 fn backup_credentials_deserialise_from_credential_process_shape() {
191 let body = json!({
192 "Version": 1,
193 "AccessKeyId": "AKIA",
194 "SecretAccessKey": "secret",
195 "SessionToken": "token",
196 "Expiration": "2026-05-21T13:00:00Z",
197 });
198 let creds: BackupCredentials = serde_json::from_value(body).unwrap();
199 assert_eq!(creds.version, 1);
200 assert_eq!(creds.access_key_id, "AKIA");
201 assert_eq!(&*creds.secret_access_key, "secret");
202 assert_eq!(&*creds.session_token, "token");
203 assert_eq!(creds.expiration.to_string(), "2026-05-21T13:00:00Z");
204 }
205
206 #[test]
207 fn container_creds_translate_session_token_to_token() {
208 let body = json!({
209 "Version": 1,
210 "AccessKeyId": "AKIA",
211 "SecretAccessKey": "secret",
212 "SessionToken": "session-token",
213 "Expiration": "2026-05-21T13:00:00Z",
214 });
215 let creds: BackupCredentials = serde_json::from_value(body).unwrap();
216 let container = ContainerCreds::from(&creds);
217 let out = serde_json::to_value(&container).unwrap();
218 assert_eq!(
219 out,
220 json!({
221 "AccessKeyId": "AKIA",
222 "SecretAccessKey": "secret",
223 "Token": "session-token",
224 "Expiration": "2026-05-21T13:00:00Z",
225 })
226 );
227 assert!(out.get("SessionToken").is_none());
229 }
230
231 #[test]
232 fn backup_target_deserialises() {
233 let body = json!({
234 "storage": "s3",
235 "bucket": "my-bucket",
236 "prefix": "",
237 "region": "ap-southeast-2",
238 "repo_password": "hunter2",
239 });
240 let target: BackupTarget = serde_json::from_value(body).unwrap();
241 assert_eq!(target.storage, "s3");
242 assert_eq!(target.bucket, "my-bucket");
243 assert_eq!(target.prefix, "");
244 assert_eq!(target.region, "ap-southeast-2");
245 assert_eq!(&*target.repo_password, "hunter2");
246 }
247
248 #[test]
249 fn backup_report_omits_optional_fields() {
250 let report = BackupReport {
251 run_id: "11111111-1111-1111-1111-111111111111",
252 r#type: "tamanu-postgres",
253 purpose: Purpose::Backup,
254 outcome: Outcome::Success,
255 error: None,
256 bytes_uploaded: None,
257 snapshot_id: None,
258 s3_sent_raw_bytes: None,
259 s3_sent_payload_bytes: None,
260 s3_received_raw_bytes: None,
261 s3_received_payload_bytes: None,
262 };
263 assert_eq!(
264 serde_json::to_value(&report).unwrap(),
265 json!({
266 "run_id": "11111111-1111-1111-1111-111111111111",
267 "type": "tamanu-postgres",
268 "purpose": "backup",
269 "outcome": "success",
270 })
271 );
272 }
273
274 #[test]
275 fn backup_report_includes_failure_fields() {
276 let report = BackupReport {
277 run_id: "run",
278 r#type: "tamanu-postgres",
279 purpose: Purpose::Backup,
280 outcome: Outcome::Failure,
281 error: Some("kopia exploded"),
282 bytes_uploaded: Some(42),
283 snapshot_id: Some("snap"),
284 s3_sent_raw_bytes: Some(2048),
285 s3_sent_payload_bytes: Some(1024),
286 s3_received_raw_bytes: Some(64),
287 s3_received_payload_bytes: Some(0),
288 };
289 assert_eq!(
290 serde_json::to_value(&report).unwrap(),
291 json!({
292 "run_id": "run",
293 "type": "tamanu-postgres",
294 "purpose": "backup",
295 "outcome": "failure",
296 "error": "kopia exploded",
297 "bytes_uploaded": 42,
298 "snapshot_id": "snap",
299 "s3_sent_raw_bytes": 2048,
300 "s3_sent_payload_bytes": 1024,
301 "s3_received_raw_bytes": 64,
302 "s3_received_payload_bytes": 0,
303 })
304 );
305 }
306
307 #[test]
308 fn redacted_debug_does_not_leak() {
309 let creds = ContainerCreds {
310 access_key_id: "AKIA".to_owned(),
311 secret_access_key: Redacted("aws-sk-value-123".to_owned()),
312 token: Redacted("aws-token-value-456".to_owned()),
313 expiration: "2026-05-21T13:00:00Z".parse().unwrap(),
314 };
315 let debug = format!("{creds:?}");
316 assert!(!debug.contains("aws-sk-value-123"));
317 assert!(!debug.contains("aws-token-value-456"));
318 assert!(debug.contains("<redacted>"));
319 }
320}