//! 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 ); } #[tokio::test] async fn applications_are_listed_and_can_be_backed_up_in_one_step() { let app = test_app_with_admin().await; let token = common::login(&app, ADMIN, PW).await.access; let target = post(&app, "/api/backups/targets", smb(), Some(&token)).await; let tid = target.json["id"].as_str().unwrap().to_string(); let res = get(&app, "/api/backups/applications", Some(&token)).await; assert_eq!(res.status, StatusCode::OK, "{}", res.json); let apps = res.json.as_array().unwrap(); let gitea = apps .iter() .find(|a| a["name"] == "Gitea") .expect("the gitea release"); assert_eq!(gitea["kind"], "kubernetes"); assert_eq!(gitea["namespace"], "gitea"); let components = gitea["components"].as_array().unwrap(); let data = components .iter() .find(|c| c["id"] == "data:gitea-shared-storage") .unwrap(); assert_eq!(data["kind"], "data"); assert_eq!(data["recommended"], true); assert!(components .iter() .any(|c| c["id"] == "db:gitea-postgresql-0" && c["kind"] == "database")); assert!(apps.iter().any(|a| a["kind"] == "host")); // "Gitea → data + database → 3am → target → save" let body = json!({ "application_id": gitea["id"], "component_ids": ["data:gitea-shared-storage", "db:gitea-postgresql-0"], "schedule": "0 0 3 * * *", "target_id": tid, "retention": 7 }); let res = post(&app, "/api/backups/applications", body, Some(&token)).await; assert_eq!(res.status, StatusCode::CREATED, "{}", res.json); let created = res.json.as_array().unwrap(); assert_eq!(created.len(), 2); assert!(created[0]["name"].as_str().unwrap().starts_with("Gitea – ")); assert_eq!(created[0]["schedule"], "0 0 3 * * *"); let strategies = get(&app, "/api/backups/strategies", Some(&token)).await; assert_eq!(strategies.json.as_array().unwrap().len(), 2); // a viewer may look but not create post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&token)).await; let user = common::login(&app, "u@x.de", "user-password-123") .await .access; assert_eq!( get(&app, "/api/backups/applications", Some(&user)) .await .status, StatusCode::OK ); let res = post(&app, "/api/backups/applications", json!({"application_id": "x", "component_ids": [], "schedule": "0 0 3 * * *", "target_id": tid, "retention": 7}), Some(&user)).await; assert_eq!(res.status, StatusCode::FORBIDDEN); }