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

@ -651,3 +651,246 @@ impl FindingRepository for MemFindings {
Ok(c)
}
}
use domain::backup::{
BackupRecord, BackupSource, BackupStrategy, BackupTarget, RemoteFile, StorageKind,
};
use domain::ports::{
BackupCollector, BackupRecordRepository, BackupStorage, BackupStrategyRepository,
BackupTargetRepository, FileEncryptor, LineSink as _LineSink,
};
use std::path::{Path, PathBuf};
#[derive(Default)]
pub struct MemTargets(pub Mutex<Vec<BackupTarget>>);
#[async_trait]
impl BackupTargetRepository for MemTargets {
async fn list(&self) -> Result<Vec<BackupTarget>, DomainError> {
Ok(self.0.lock().unwrap().clone())
}
async fn get(&self, id: Uuid) -> Result<Option<BackupTarget>, DomainError> {
Ok(self.0.lock().unwrap().iter().find(|t| t.id == id).cloned())
}
async fn insert(&self, t: &BackupTarget) -> Result<(), DomainError> {
self.0.lock().unwrap().push(t.clone());
Ok(())
}
async fn update(&self, t: &BackupTarget) -> Result<(), DomainError> {
let mut v = self.0.lock().unwrap();
let x = v
.iter_mut()
.find(|x| x.id == t.id)
.ok_or(DomainError::NotFound)?;
*x = t.clone();
Ok(())
}
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
let mut v = self.0.lock().unwrap();
let before = v.len();
v.retain(|t| t.id != id);
(v.len() < before)
.then_some(())
.ok_or(DomainError::NotFound)
}
}
#[derive(Default)]
pub struct MemStrategies(pub Mutex<Vec<BackupStrategy>>);
#[async_trait]
impl BackupStrategyRepository for MemStrategies {
async fn list(&self) -> Result<Vec<BackupStrategy>, DomainError> {
Ok(self.0.lock().unwrap().clone())
}
async fn get(&self, id: Uuid) -> Result<Option<BackupStrategy>, DomainError> {
Ok(self.0.lock().unwrap().iter().find(|t| t.id == id).cloned())
}
async fn insert(&self, s: &BackupStrategy) -> Result<(), DomainError> {
self.0.lock().unwrap().push(s.clone());
Ok(())
}
async fn update(&self, s: &BackupStrategy) -> Result<(), DomainError> {
let mut v = self.0.lock().unwrap();
let x = v
.iter_mut()
.find(|x| x.id == s.id)
.ok_or(DomainError::NotFound)?;
*x = s.clone();
Ok(())
}
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
let mut v = self.0.lock().unwrap();
let before = v.len();
v.retain(|t| t.id != id);
(v.len() < before)
.then_some(())
.ok_or(DomainError::NotFound)
}
}
#[derive(Default)]
pub struct MemRecords(pub Mutex<Vec<BackupRecord>>);
#[async_trait]
impl BackupRecordRepository for MemRecords {
async fn insert(&self, r: &BackupRecord) -> Result<(), DomainError> {
self.0.lock().unwrap().push(r.clone());
Ok(())
}
async fn list_for(
&self,
strategy_id: Uuid,
limit: u32,
) -> Result<Vec<BackupRecord>, DomainError> {
let mut v: Vec<_> = self
.0
.lock()
.unwrap()
.iter()
.filter(|r| r.strategy_id == strategy_id)
.cloned()
.collect();
v.sort_by(|a, b| b.created_at.cmp(&a.created_at));
v.truncate(limit as usize);
Ok(v)
}
async fn delete_by_filename(
&self,
strategy_id: Uuid,
filename: &str,
) -> Result<(), DomainError> {
self.0
.lock()
.unwrap()
.retain(|r| !(r.strategy_id == strategy_id && r.filename == filename));
Ok(())
}
}
/// In-memory remote storage keyed by target id; `fail` makes every call fail.
#[derive(Default)]
pub struct MemStorage {
pub files: Mutex<HashMap<Uuid, Vec<RemoteFile>>>,
pub fail: bool,
pub ops: Mutex<Vec<String>>,
}
#[async_trait]
impl BackupStorage for MemStorage {
async fn test(&self, t: &BackupTarget) -> Result<(), DomainError> {
self.ops.lock().unwrap().push(format!("test {}", t.name));
if self.fail {
Err(DomainError::Unavailable("connection refused".into()))
} else {
Ok(())
}
}
async fn upload(
&self,
t: &BackupTarget,
local: &Path,
remote_name: &str,
) -> Result<(), DomainError> {
if self.fail {
return Err(DomainError::Unavailable("upload failed".into()));
}
let size = std::fs::metadata(local).map(|m| m.len()).unwrap_or(0);
self.ops
.lock()
.unwrap()
.push(format!("upload {remote_name}"));
self.files
.lock()
.unwrap()
.entry(t.id)
.or_default()
.push(RemoteFile {
name: remote_name.into(),
size_bytes: size,
});
Ok(())
}
async fn list(&self, t: &BackupTarget) -> Result<Vec<RemoteFile>, DomainError> {
Ok(self
.files
.lock()
.unwrap()
.get(&t.id)
.cloned()
.unwrap_or_default())
}
async fn delete(&self, t: &BackupTarget, remote_name: &str) -> Result<(), DomainError> {
self.ops
.lock()
.unwrap()
.push(format!("delete {remote_name}"));
self.files
.lock()
.unwrap()
.entry(t.id)
.or_default()
.retain(|f| f.name != remote_name);
Ok(())
}
}
/// Writes a small file describing the source.
pub struct FakeCollector;
#[async_trait]
impl BackupCollector for FakeCollector {
async fn collect(
&self,
source: &BackupSource,
work_dir: &Path,
out: &dyn _LineSink,
) -> Result<PathBuf, DomainError> {
out.line(&format!("collecting {source:?}"));
let p = work_dir.join(format!("archive.{}", source.extension()));
std::fs::write(&p, format!("fake archive of {source:?}"))
.map_err(|e| DomainError::Storage(e.to_string()))?;
Ok(p)
}
}
pub struct FakeEncryptor;
#[async_trait]
impl FileEncryptor for FakeEncryptor {
async fn encrypt(&self, input: &Path, passphrase: &str) -> Result<PathBuf, DomainError> {
let out = input.with_extension(format!(
"{}.enc",
input.extension().and_then(|e| e.to_str()).unwrap_or("")
));
let data = std::fs::read(input).map_err(|e| DomainError::Storage(e.to_string()))?;
std::fs::write(&out, [b"ENC:", passphrase.as_bytes(), b":", &data].concat())
.map_err(|e| DomainError::Storage(e.to_string()))?;
Ok(out)
}
}
pub fn target(name: &str) -> BackupTarget {
BackupTarget {
id: Uuid::new_v4(),
name: name.into(),
kind: StorageKind::Smb,
host: "nas.local".into(),
port: None,
share: "backups".into(),
path: "softvisor".into(),
username: "backup".into(),
password: "smb-secret".into(),
tls: false,
}
}
pub fn strategy(name: &str, target_id: Uuid) -> BackupStrategy {
BackupStrategy {
id: Uuid::new_v4(),
name: name.into(),
source: BackupSource::PostgresDump {
namespace: "gitea".into(),
pod: "gitea-postgresql-0".into(),
},
schedule: "0 0 2 * * *".into(),
target_id,
retention: 2,
passphrase: None,
enabled: true,
}
}