WP-30/31/32: contract and failing tests for backup management; OS scan skips container dirs
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 23:05:22 +02:00
parent 5026ce22de
commit 39af18b336
16 changed files with 1456 additions and 1 deletions

View 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"));
}

View File

@ -1,4 +1,5 @@
mod auth_service_tests;
mod backup_tests;
mod cluster_tests;
mod inventory_tests;
mod jobs_tests;