WP-30/31/32: contract and failing tests for backup management; OS scan skips container dirs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
374
backend/crates/application/src/tests/backup_tests.rs
Normal file
374
backend/crates/application/src/tests/backup_tests.rs
Normal file
@ -0,0 +1,374 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, TimeZone, Utc};
|
||||
use domain::backup::{BackupSource, StorageKind};
|
||||
use domain::DomainError;
|
||||
|
||||
use crate::jobs::{JobHandler, JobLog};
|
||||
use crate::test_fakes::{
|
||||
strategy, target, FakeCipher, FakeCollector, FakeEncryptor, MemRecords, MemStorage,
|
||||
MemStrategies, MemTargets,
|
||||
};
|
||||
use crate::{BackupDeps, BackupJob, BackupService};
|
||||
|
||||
#[derive(Default)]
|
||||
struct VecLog(Mutex<Vec<String>>);
|
||||
#[async_trait]
|
||||
impl JobLog for VecLog {
|
||||
async fn line(&self, text: &str) {
|
||||
self.0.lock().unwrap().push(text.into());
|
||||
}
|
||||
}
|
||||
|
||||
struct F {
|
||||
targets: Arc<MemTargets>,
|
||||
strategies: Arc<MemStrategies>,
|
||||
records: Arc<MemRecords>,
|
||||
storage: Arc<MemStorage>,
|
||||
work: tempfile::TempDir,
|
||||
}
|
||||
|
||||
fn fixture(fail_storage: bool) -> (F, Arc<BackupService>) {
|
||||
let targets = Arc::new(MemTargets::default());
|
||||
let strategies = Arc::new(MemStrategies::default());
|
||||
let records = Arc::new(MemRecords::default());
|
||||
let storage = Arc::new(MemStorage {
|
||||
fail: fail_storage,
|
||||
..Default::default()
|
||||
});
|
||||
let work = tempfile::tempdir().unwrap();
|
||||
let svc = BackupService::new(BackupDeps {
|
||||
targets: targets.clone(),
|
||||
strategies: strategies.clone(),
|
||||
records: records.clone(),
|
||||
storage: storage.clone(),
|
||||
collector: Arc::new(FakeCollector),
|
||||
encryptor: Arc::new(FakeEncryptor),
|
||||
cipher: Arc::new(FakeCipher),
|
||||
work_dir: work.path().to_path_buf(),
|
||||
});
|
||||
(
|
||||
F {
|
||||
targets,
|
||||
strategies,
|
||||
records,
|
||||
storage,
|
||||
work,
|
||||
},
|
||||
Arc::new(svc),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn targets_crud_with_encrypted_password_and_masked_update() {
|
||||
let (f, svc) = fixture(false);
|
||||
let t = svc.create_target(target("NAS")).await.unwrap();
|
||||
assert_eq!(t.password, "smb-secret", "returned in plain text");
|
||||
let stored = f.targets.0.lock().unwrap()[0].clone();
|
||||
assert!(stored.password.starts_with("enc:"), "stored encrypted");
|
||||
assert_eq!(svc.get_target(t.id).await.unwrap().password, "smb-secret");
|
||||
|
||||
// update with empty password keeps the old one
|
||||
let upd = svc
|
||||
.update_target(domain::backup::BackupTarget {
|
||||
password: String::new(),
|
||||
host: "nas2.local".into(),
|
||||
..t.clone()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(upd.host, "nas2.local");
|
||||
assert_eq!(upd.password, "smb-secret");
|
||||
let upd = svc
|
||||
.update_target(domain::backup::BackupTarget {
|
||||
password: "new-secret".into(),
|
||||
..t.clone()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(upd.password, "new-secret");
|
||||
|
||||
let mut bad = target("x");
|
||||
bad.share = String::new();
|
||||
assert!(matches!(
|
||||
svc.create_target(bad).await.unwrap_err(),
|
||||
DomainError::Validation(_)
|
||||
));
|
||||
let mut ftp = target("ftp");
|
||||
ftp.kind = StorageKind::Ftp;
|
||||
ftp.share = String::new();
|
||||
svc.create_target(ftp).await.unwrap();
|
||||
assert_eq!(svc.list_targets().await.unwrap().len(), 2);
|
||||
|
||||
svc.test_target(t.id).await.unwrap();
|
||||
assert_eq!(f.storage.ops.lock().unwrap()[0], "test NAS");
|
||||
svc.delete_target(t.id).await.unwrap();
|
||||
assert_eq!(
|
||||
svc.get_target(t.id).await.unwrap_err(),
|
||||
DomainError::NotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn strategies_crud_validation_and_target_protection() {
|
||||
let (f, svc) = fixture(false);
|
||||
let t = svc.create_target(target("NAS")).await.unwrap();
|
||||
let mut s = strategy("Gitea DB", t.id);
|
||||
s.passphrase = Some("a-long-passphrase!".into());
|
||||
let created = svc.create_strategy(s.clone()).await.unwrap();
|
||||
assert!(f.strategies.0.lock().unwrap()[0]
|
||||
.passphrase
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.starts_with("enc:"));
|
||||
assert_eq!(
|
||||
svc.get_strategy(created.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.passphrase
|
||||
.as_deref(),
|
||||
Some("a-long-passphrase!")
|
||||
);
|
||||
|
||||
// validation: cron, retention, unknown target, source names
|
||||
let bad = domain::backup::BackupStrategy {
|
||||
schedule: "nope".into(),
|
||||
..s.clone()
|
||||
};
|
||||
assert!(matches!(
|
||||
svc.create_strategy(bad).await.unwrap_err(),
|
||||
DomainError::Validation(_)
|
||||
));
|
||||
let bad = domain::backup::BackupStrategy {
|
||||
retention: 0,
|
||||
..s.clone()
|
||||
};
|
||||
assert!(matches!(
|
||||
svc.create_strategy(bad).await.unwrap_err(),
|
||||
DomainError::Validation(_)
|
||||
));
|
||||
let bad = domain::backup::BackupStrategy {
|
||||
target_id: uuid::Uuid::new_v4(),
|
||||
..s.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
svc.create_strategy(bad).await.unwrap_err(),
|
||||
DomainError::NotFound
|
||||
);
|
||||
let bad = domain::backup::BackupStrategy {
|
||||
source: BackupSource::HostPath {
|
||||
path: "relative/../x".into(),
|
||||
},
|
||||
..s.clone()
|
||||
};
|
||||
assert!(matches!(
|
||||
svc.create_strategy(bad).await.unwrap_err(),
|
||||
DomainError::Validation(_)
|
||||
));
|
||||
|
||||
// target in use cannot be deleted
|
||||
assert!(matches!(
|
||||
svc.delete_target(t.id).await.unwrap_err(),
|
||||
DomainError::Conflict(_)
|
||||
));
|
||||
|
||||
// update: empty passphrase keeps, None removes
|
||||
let upd = svc
|
||||
.update_strategy(domain::backup::BackupStrategy {
|
||||
passphrase: Some(String::new()),
|
||||
retention: 5,
|
||||
..created.clone()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(upd.passphrase.as_deref(), Some("a-long-passphrase!"));
|
||||
assert_eq!(upd.retention, 5);
|
||||
let upd = svc
|
||||
.update_strategy(domain::backup::BackupStrategy {
|
||||
passphrase: None,
|
||||
..created.clone()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(upd.passphrase, None);
|
||||
|
||||
let list = svc.list_strategies().await.unwrap();
|
||||
assert_eq!(list[0].target_name, "NAS");
|
||||
assert_eq!(list[0].last_backup, None);
|
||||
svc.delete_strategy(created.id).await.unwrap();
|
||||
svc.delete_target(t.id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_uploads_archive_records_it_and_applies_retention() {
|
||||
let (f, svc) = fixture(false);
|
||||
let t = svc.create_target(target("NAS")).await.unwrap();
|
||||
let s = svc
|
||||
.create_strategy(strategy("Gitea DB", t.id))
|
||||
.await
|
||||
.unwrap();
|
||||
let log = VecLog::default();
|
||||
let r1 = svc.run_strategy(s.id, &log).await.unwrap();
|
||||
assert!(
|
||||
r1.filename.starts_with("gitea-db_") && r1.filename.ends_with(".sql.gz"),
|
||||
"{}",
|
||||
r1.filename
|
||||
);
|
||||
assert!(r1.size_bytes > 0);
|
||||
assert_eq!(r1.sha256.len(), 64);
|
||||
let lines = log.0.lock().unwrap().join("\n");
|
||||
assert!(lines.contains("collecting"), "{lines}");
|
||||
assert!(lines.contains("uploaded"), "{lines}");
|
||||
assert!(
|
||||
f.work.path().read_dir().unwrap().next().is_none(),
|
||||
"work dir cleaned up"
|
||||
);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
svc.run_strategy(s.id, &VecLog::default()).await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
let r3 = svc.run_strategy(s.id, &VecLog::default()).await.unwrap();
|
||||
|
||||
// retention 2: oldest removed remotely and from records
|
||||
let remote = f.storage.files.lock().unwrap().get(&t.id).cloned().unwrap();
|
||||
assert_eq!(remote.len(), 2, "{remote:?}");
|
||||
assert!(remote.iter().all(|x| x.name != r1.filename));
|
||||
assert!(f
|
||||
.storage
|
||||
.ops
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&format!("delete {}", r1.filename)));
|
||||
let recs = svc.records(s.id).await.unwrap();
|
||||
assert_eq!(recs.len(), 2);
|
||||
assert_eq!(recs[0].filename, r3.filename, "newest first");
|
||||
assert_eq!(
|
||||
svc.list_strategies().await.unwrap()[0]
|
||||
.last_backup
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.filename,
|
||||
r3.filename
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn encrypted_strategy_uploads_enc_file() {
|
||||
let (f, svc) = fixture(false);
|
||||
let t = svc.create_target(target("NAS")).await.unwrap();
|
||||
let mut s = strategy("Repos", t.id);
|
||||
s.source = BackupSource::VolumeClaim {
|
||||
namespace: "gitea".into(),
|
||||
pvc: "gitea-shared-storage".into(),
|
||||
};
|
||||
s.passphrase = Some("correct horse battery".into());
|
||||
let s = svc.create_strategy(s).await.unwrap();
|
||||
let r = svc.run_strategy(s.id, &VecLog::default()).await.unwrap();
|
||||
assert!(r.filename.ends_with(".tar.gz.enc"), "{}", r.filename);
|
||||
let remote = f.storage.files.lock().unwrap().get(&t.id).cloned().unwrap();
|
||||
assert_eq!(remote[0].name, r.filename);
|
||||
assert_eq!(remote[0].size_bytes, r.size_bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_failure_fails_run_and_cleans_up() {
|
||||
let (f, svc) = fixture(true);
|
||||
let t = svc.create_target(target("NAS")).await.unwrap();
|
||||
let s = svc.create_strategy(strategy("x", t.id)).await.unwrap();
|
||||
let log = VecLog::default();
|
||||
assert!(matches!(
|
||||
svc.run_strategy(s.id, &log).await.unwrap_err(),
|
||||
DomainError::Unavailable(_)
|
||||
));
|
||||
assert!(f.records.0.lock().unwrap().is_empty());
|
||||
assert!(
|
||||
f.work.path().read_dir().unwrap().next().is_none(),
|
||||
"work dir cleaned up"
|
||||
);
|
||||
assert!(matches!(
|
||||
svc.test_target(t.id).await.unwrap_err(),
|
||||
DomainError::Unavailable(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn due_strategies_follow_cron_and_last_backup() {
|
||||
let (f, svc) = fixture(false);
|
||||
let t = svc.create_target(target("NAS")).await.unwrap();
|
||||
let daily = svc.create_strategy(strategy("daily", t.id)).await.unwrap(); // 02:00
|
||||
let mut off = strategy("off", t.id);
|
||||
off.enabled = false;
|
||||
let off = svc.create_strategy(off).await.unwrap();
|
||||
let now = Utc.with_ymd_and_hms(2026, 9, 3, 2, 0, 30).unwrap();
|
||||
// never ran: due within grace after 02:00
|
||||
assert_eq!(svc.due_strategies(now, 120).await.unwrap(), vec![daily.id]);
|
||||
assert!(svc
|
||||
.due_strategies(now + Duration::hours(1), 120)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
// ran yesterday -> due again at 02:00 today, even if the tick is late
|
||||
f.records
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(domain::backup::BackupRecord {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
strategy_id: daily.id,
|
||||
filename: "daily_x".into(),
|
||||
size_bytes: 1,
|
||||
sha256: String::new(),
|
||||
created_at: now - Duration::days(1),
|
||||
});
|
||||
assert_eq!(
|
||||
svc.due_strategies(now + Duration::hours(1), 120)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![daily.id]
|
||||
);
|
||||
// ran just now -> not due
|
||||
f.records
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(domain::backup::BackupRecord {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
strategy_id: daily.id,
|
||||
filename: "daily_y".into(),
|
||||
size_bytes: 1,
|
||||
sha256: String::new(),
|
||||
created_at: now,
|
||||
});
|
||||
assert!(svc
|
||||
.due_strategies(now + Duration::hours(1), 120)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
let _ = off;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_job_runs_strategy_from_params() {
|
||||
let (_, svc) = fixture(false);
|
||||
let t = svc.create_target(target("NAS")).await.unwrap();
|
||||
let s = svc.create_strategy(strategy("job", t.id)).await.unwrap();
|
||||
let job = BackupJob(svc.clone());
|
||||
let log = VecLog::default();
|
||||
job.run(Some(s.id.to_string()), &log).await.unwrap();
|
||||
assert!(log.0.lock().unwrap().iter().any(|l| l.contains("job_")));
|
||||
assert!(job
|
||||
.run(None, &VecLog::default())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("strategy"));
|
||||
assert!(job
|
||||
.run(Some("not-a-uuid".into()), &VecLog::default())
|
||||
.await
|
||||
.is_err());
|
||||
assert!(job
|
||||
.run(Some(uuid::Uuid::new_v4().to_string()), &VecLog::default())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("not found"));
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
mod auth_service_tests;
|
||||
mod backup_tests;
|
||||
mod cluster_tests;
|
||||
mod inventory_tests;
|
||||
mod jobs_tests;
|
||||
|
||||
Reference in New Issue
Block a user