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}
119
120#[cfg(test)]
121mod tests {
122 use serde_json::json;
123
124 use super::*;
125
126 #[test]
127 fn purpose_serialises_lowercase() {
128 assert_eq!(
129 serde_json::to_value(Purpose::Backup).unwrap(),
130 json!("backup")
131 );
132 assert_eq!(
133 serde_json::to_value(Purpose::Restore).unwrap(),
134 json!("restore")
135 );
136 }
137
138 #[test]
139 fn purpose_defaults_to_backup() {
140 assert_eq!(Purpose::default(), Purpose::Backup);
141 }
142
143 #[test]
144 fn outcome_serialises_lowercase() {
145 assert_eq!(
146 serde_json::to_value(Outcome::Success).unwrap(),
147 json!("success")
148 );
149 assert_eq!(
150 serde_json::to_value(Outcome::Failure).unwrap(),
151 json!("failure")
152 );
153 }
154
155 #[test]
156 fn credentials_request_carries_type_and_purpose() {
157 let req = BackupCredentialsRequest {
158 r#type: "tamanu-postgres",
159 purpose: Purpose::Backup,
160 };
161 assert_eq!(
162 serde_json::to_value(&req).unwrap(),
163 json!({"type": "tamanu-postgres", "purpose": "backup"})
164 );
165 }
166
167 #[test]
168 fn capabilities_request_lists_types() {
169 let types = vec!["tamanu-postgres".to_owned(), "files".to_owned()];
170 let req = CapabilitiesRequest { types: &types };
171 assert_eq!(
172 serde_json::to_value(&req).unwrap(),
173 json!({"types": ["tamanu-postgres", "files"]})
174 );
175 }
176
177 #[test]
178 fn backup_credentials_deserialise_from_credential_process_shape() {
179 let body = json!({
180 "Version": 1,
181 "AccessKeyId": "AKIA",
182 "SecretAccessKey": "secret",
183 "SessionToken": "token",
184 "Expiration": "2026-05-21T13:00:00Z",
185 });
186 let creds: BackupCredentials = serde_json::from_value(body).unwrap();
187 assert_eq!(creds.version, 1);
188 assert_eq!(creds.access_key_id, "AKIA");
189 assert_eq!(&*creds.secret_access_key, "secret");
190 assert_eq!(&*creds.session_token, "token");
191 assert_eq!(creds.expiration.to_string(), "2026-05-21T13:00:00Z");
192 }
193
194 #[test]
195 fn container_creds_translate_session_token_to_token() {
196 let body = json!({
197 "Version": 1,
198 "AccessKeyId": "AKIA",
199 "SecretAccessKey": "secret",
200 "SessionToken": "session-token",
201 "Expiration": "2026-05-21T13:00:00Z",
202 });
203 let creds: BackupCredentials = serde_json::from_value(body).unwrap();
204 let container = ContainerCreds::from(&creds);
205 let out = serde_json::to_value(&container).unwrap();
206 assert_eq!(
207 out,
208 json!({
209 "AccessKeyId": "AKIA",
210 "SecretAccessKey": "secret",
211 "Token": "session-token",
212 "Expiration": "2026-05-21T13:00:00Z",
213 })
214 );
215 assert!(out.get("SessionToken").is_none());
217 }
218
219 #[test]
220 fn backup_target_deserialises() {
221 let body = json!({
222 "storage": "s3",
223 "bucket": "my-bucket",
224 "prefix": "",
225 "region": "ap-southeast-2",
226 "repo_password": "hunter2",
227 });
228 let target: BackupTarget = serde_json::from_value(body).unwrap();
229 assert_eq!(target.storage, "s3");
230 assert_eq!(target.bucket, "my-bucket");
231 assert_eq!(target.prefix, "");
232 assert_eq!(target.region, "ap-southeast-2");
233 assert_eq!(&*target.repo_password, "hunter2");
234 }
235
236 #[test]
237 fn backup_report_omits_optional_fields() {
238 let report = BackupReport {
239 run_id: "11111111-1111-1111-1111-111111111111",
240 r#type: "tamanu-postgres",
241 purpose: Purpose::Backup,
242 outcome: Outcome::Success,
243 error: None,
244 bytes_uploaded: None,
245 snapshot_id: None,
246 };
247 assert_eq!(
248 serde_json::to_value(&report).unwrap(),
249 json!({
250 "run_id": "11111111-1111-1111-1111-111111111111",
251 "type": "tamanu-postgres",
252 "purpose": "backup",
253 "outcome": "success",
254 })
255 );
256 }
257
258 #[test]
259 fn backup_report_includes_failure_fields() {
260 let report = BackupReport {
261 run_id: "run",
262 r#type: "tamanu-postgres",
263 purpose: Purpose::Backup,
264 outcome: Outcome::Failure,
265 error: Some("kopia exploded"),
266 bytes_uploaded: Some(42),
267 snapshot_id: Some("snap"),
268 };
269 assert_eq!(
270 serde_json::to_value(&report).unwrap(),
271 json!({
272 "run_id": "run",
273 "type": "tamanu-postgres",
274 "purpose": "backup",
275 "outcome": "failure",
276 "error": "kopia exploded",
277 "bytes_uploaded": 42,
278 "snapshot_id": "snap",
279 })
280 );
281 }
282
283 #[test]
284 fn redacted_debug_does_not_leak() {
285 let creds = ContainerCreds {
286 access_key_id: "AKIA".to_owned(),
287 secret_access_key: Redacted("aws-sk-value-123".to_owned()),
288 token: Redacted("aws-token-value-456".to_owned()),
289 expiration: "2026-05-21T13:00:00Z".parse().unwrap(),
290 };
291 let debug = format!("{creds:?}");
292 assert!(!debug.contains("aws-sk-value-123"));
293 assert!(!debug.contains("aws-token-value-456"));
294 assert!(debug.contains("<redacted>"));
295 }
296}