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,168 @@
//! WP-30/31/32: /api/backups
mod common;
use axum::http::StatusCode;
use common::{get, post, send_json, test_app_with_admin};
use serde_json::json;
const ADMIN: &str = "admin@example.com";
const PW: &str = "admin-password-123";
fn smb() -> serde_json::Value {
json!({"name": "NAS", "kind": "smb", "host": "nas.local", "share": "backups", "path": "softvisor", "username": "backup", "password": "smb-secret"})
}
#[tokio::test]
async fn target_and_strategy_lifecycle_with_manual_run() {
let app = test_app_with_admin().await;
let token = common::login(&app, ADMIN, PW).await.access;
let res = post(&app, "/api/backups/targets", smb(), Some(&token)).await;
assert_eq!(res.status, StatusCode::CREATED, "{}", res.json);
let tid = res.json["id"].as_str().unwrap().to_string();
assert!(res.json.get("password").is_none());
assert_eq!(res.json["password_set"], true);
assert_eq!(
post(
&app,
&format!("/api/backups/targets/{tid}/test"),
json!({}),
Some(&token)
)
.await
.status,
StatusCode::NO_CONTENT
);
let bad = json!({"name": "x", "kind": "ftp", "host": "", "path": ""});
assert_eq!(
post(&app, "/api/backups/targets", bad, Some(&token))
.await
.status,
StatusCode::UNPROCESSABLE_ENTITY
);
let strategy = json!({"name": "Gitea DB", "source": {"type": "postgres_dump", "namespace": "gitea", "pod": "gitea-postgresql-0"},
"schedule": "0 0 2 * * *", "target_id": tid, "retention": 3, "passphrase": "a-long-passphrase!", "enabled": true});
let res = post(&app, "/api/backups/strategies", strategy, Some(&token)).await;
assert_eq!(res.status, StatusCode::CREATED, "{}", res.json);
let sid = res.json["id"].as_str().unwrap().to_string();
assert!(res.json.get("passphrase").is_none());
assert_eq!(res.json["encrypted"], true);
// target in use -> 409
assert_eq!(
send_json(
&app,
"DELETE",
&format!("/api/backups/targets/{tid}"),
json!({}),
Some(&token)
)
.await
.status,
StatusCode::CONFLICT
);
let run = post(
&app,
&format!("/api/backups/strategies/{sid}/run"),
json!({}),
Some(&token),
)
.await;
assert_eq!(run.status, StatusCode::ACCEPTED, "{}", run.json);
assert_eq!(run.json["kind"], "backup");
let id = run.json["id"].as_str().unwrap();
let mut status = String::new();
for _ in 0..100 {
let r = get(&app, &format!("/api/jobs/{id}"), Some(&token)).await;
status = r.json["status"].as_str().unwrap().into();
if status != "running" {
assert_eq!(status, "success", "{}", r.json["log"]);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
assert_eq!(status, "success");
let recs = get(
&app,
&format!("/api/backups/strategies/{sid}/records"),
Some(&token),
)
.await;
let recs = recs.json.as_array().unwrap();
assert_eq!(recs.len(), 1);
assert!(recs[0]["filename"]
.as_str()
.unwrap()
.ends_with(".sql.gz.enc"));
let list = get(&app, "/api/backups/strategies", Some(&token)).await;
assert_eq!(list.json[0]["target_name"], "NAS");
assert_eq!(list.json[0]["last_backup"]["filename"], recs[0]["filename"]);
let upd = send_json(&app, "PUT", &format!("/api/backups/strategies/{sid}"), json!({"name": "Gitea DB", "source": {"type": "postgres_dump", "namespace": "gitea", "pod": "gitea-postgresql-0"},
"schedule": "0 0 3 * * *", "target_id": tid, "retention": 3, "passphrase": "", "enabled": false}), Some(&token)).await;
assert_eq!(upd.status, StatusCode::OK, "{}", upd.json);
assert_eq!(upd.json["enabled"], false);
assert_eq!(
upd.json["encrypted"], true,
"empty passphrase keeps the stored one"
);
assert_eq!(
send_json(
&app,
"DELETE",
&format!("/api/backups/strategies/{sid}"),
json!({}),
Some(&token)
)
.await
.status,
StatusCode::NO_CONTENT
);
assert_eq!(
send_json(
&app,
"DELETE",
&format!("/api/backups/targets/{tid}"),
json!({}),
Some(&token)
)
.await
.status,
StatusCode::NO_CONTENT
);
}
#[tokio::test]
async fn backups_are_admin_only_for_writes() {
let app = test_app_with_admin().await;
let admin = common::login(&app, ADMIN, PW).await.access;
post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&admin)).await;
let user = common::login(&app, "u@x.de", "user-password-123")
.await
.access;
assert_eq!(
get(&app, "/api/backups/targets", Some(&user)).await.status,
StatusCode::OK
);
assert_eq!(
get(&app, "/api/backups/strategies", Some(&user))
.await
.status,
StatusCode::OK
);
assert_eq!(
post(&app, "/api/backups/targets", smb(), Some(&user))
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(
get(&app, "/api/backups/targets", None).await.status,
StatusCode::UNAUTHORIZED
);
}