use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use crate::Redacted;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Purpose {
#[default]
Backup,
Restore,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Outcome {
Success,
Failure,
}
#[derive(Debug, Clone, Serialize)]
pub struct CapabilitiesRequest<'a> {
pub types: &'a [String],
}
#[derive(Debug, Clone, Serialize)]
pub struct BackupCredentialsRequest<'a> {
pub r#type: &'a str,
pub purpose: Purpose,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct BackupCredentials {
pub version: u32,
pub access_key_id: String,
pub secret_access_key: Redacted<String>,
pub session_token: Redacted<String>,
pub expiration: Timestamp,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct ContainerCreds {
pub access_key_id: String,
pub secret_access_key: Redacted<String>,
pub token: Redacted<String>,
pub expiration: Timestamp,
}
impl From<&BackupCredentials> for ContainerCreds {
fn from(c: &BackupCredentials) -> Self {
Self {
access_key_id: c.access_key_id.clone(),
secret_access_key: c.secret_access_key.clone(),
token: c.session_token.clone(),
expiration: c.expiration,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct BackupTarget {
pub storage: String,
pub bucket: String,
#[serde(default)]
pub prefix: String,
pub region: String,
pub repo_password: Redacted<String>,
}
#[derive(Debug, Clone)]
pub enum TargetOutcome {
Ready(BackupTarget),
Dormant,
}
#[derive(Debug, Clone, Serialize)]
pub struct BackupReport<'a> {
pub run_id: &'a str,
pub r#type: &'a str,
pub purpose: Purpose,
pub outcome: Outcome,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes_uploaded: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snapshot_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub s3_sent_raw_bytes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub s3_sent_payload_bytes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub s3_received_raw_bytes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub s3_received_payload_bytes: Option<i64>,
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn purpose_serialises_lowercase() {
assert_eq!(
serde_json::to_value(Purpose::Backup).unwrap(),
json!("backup")
);
assert_eq!(
serde_json::to_value(Purpose::Restore).unwrap(),
json!("restore")
);
}
#[test]
fn purpose_defaults_to_backup() {
assert_eq!(Purpose::default(), Purpose::Backup);
}
#[test]
fn outcome_serialises_lowercase() {
assert_eq!(
serde_json::to_value(Outcome::Success).unwrap(),
json!("success")
);
assert_eq!(
serde_json::to_value(Outcome::Failure).unwrap(),
json!("failure")
);
}
#[test]
fn credentials_request_carries_type_and_purpose() {
let req = BackupCredentialsRequest {
r#type: "tamanu-postgres",
purpose: Purpose::Backup,
};
assert_eq!(
serde_json::to_value(&req).unwrap(),
json!({"type": "tamanu-postgres", "purpose": "backup"})
);
}
#[test]
fn capabilities_request_lists_types() {
let types = vec!["tamanu-postgres".to_owned(), "files".to_owned()];
let req = CapabilitiesRequest { types: &types };
assert_eq!(
serde_json::to_value(&req).unwrap(),
json!({"types": ["tamanu-postgres", "files"]})
);
}
#[test]
fn backup_credentials_deserialise_from_credential_process_shape() {
let body = json!({
"Version": 1,
"AccessKeyId": "AKIA",
"SecretAccessKey": "secret",
"SessionToken": "token",
"Expiration": "2026-05-21T13:00:00Z",
});
let creds: BackupCredentials = serde_json::from_value(body).unwrap();
assert_eq!(creds.version, 1);
assert_eq!(creds.access_key_id, "AKIA");
assert_eq!(&*creds.secret_access_key, "secret");
assert_eq!(&*creds.session_token, "token");
assert_eq!(creds.expiration.to_string(), "2026-05-21T13:00:00Z");
}
#[test]
fn container_creds_translate_session_token_to_token() {
let body = json!({
"Version": 1,
"AccessKeyId": "AKIA",
"SecretAccessKey": "secret",
"SessionToken": "session-token",
"Expiration": "2026-05-21T13:00:00Z",
});
let creds: BackupCredentials = serde_json::from_value(body).unwrap();
let container = ContainerCreds::from(&creds);
let out = serde_json::to_value(&container).unwrap();
assert_eq!(
out,
json!({
"AccessKeyId": "AKIA",
"SecretAccessKey": "secret",
"Token": "session-token",
"Expiration": "2026-05-21T13:00:00Z",
})
);
assert!(out.get("SessionToken").is_none());
}
#[test]
fn backup_target_deserialises() {
let body = json!({
"storage": "s3",
"bucket": "my-bucket",
"prefix": "",
"region": "ap-southeast-2",
"repo_password": "hunter2",
});
let target: BackupTarget = serde_json::from_value(body).unwrap();
assert_eq!(target.storage, "s3");
assert_eq!(target.bucket, "my-bucket");
assert_eq!(target.prefix, "");
assert_eq!(target.region, "ap-southeast-2");
assert_eq!(&*target.repo_password, "hunter2");
}
#[test]
fn backup_report_omits_optional_fields() {
let report = BackupReport {
run_id: "11111111-1111-1111-1111-111111111111",
r#type: "tamanu-postgres",
purpose: Purpose::Backup,
outcome: Outcome::Success,
error: None,
bytes_uploaded: None,
snapshot_id: None,
s3_sent_raw_bytes: None,
s3_sent_payload_bytes: None,
s3_received_raw_bytes: None,
s3_received_payload_bytes: None,
};
assert_eq!(
serde_json::to_value(&report).unwrap(),
json!({
"run_id": "11111111-1111-1111-1111-111111111111",
"type": "tamanu-postgres",
"purpose": "backup",
"outcome": "success",
})
);
}
#[test]
fn backup_report_includes_failure_fields() {
let report = BackupReport {
run_id: "run",
r#type: "tamanu-postgres",
purpose: Purpose::Backup,
outcome: Outcome::Failure,
error: Some("kopia exploded"),
bytes_uploaded: Some(42),
snapshot_id: Some("snap"),
s3_sent_raw_bytes: Some(2048),
s3_sent_payload_bytes: Some(1024),
s3_received_raw_bytes: Some(64),
s3_received_payload_bytes: Some(0),
};
assert_eq!(
serde_json::to_value(&report).unwrap(),
json!({
"run_id": "run",
"type": "tamanu-postgres",
"purpose": "backup",
"outcome": "failure",
"error": "kopia exploded",
"bytes_uploaded": 42,
"snapshot_id": "snap",
"s3_sent_raw_bytes": 2048,
"s3_sent_payload_bytes": 1024,
"s3_received_raw_bytes": 64,
"s3_received_payload_bytes": 0,
})
);
}
#[test]
fn redacted_debug_does_not_leak() {
let creds = ContainerCreds {
access_key_id: "AKIA".to_owned(),
secret_access_key: Redacted("aws-sk-value-123".to_owned()),
token: Redacted("aws-token-value-456".to_owned()),
expiration: "2026-05-21T13:00:00Z".parse().unwrap(),
};
let debug = format!("{creds:?}");
assert!(!debug.contains("aws-sk-value-123"));
assert!(!debug.contains("aws-token-value-456"));
assert!(debug.contains("<redacted>"));
}
}