//! Backups (docs/backups.md, oxygenhub#169), end to end: a real `oxy` push, //! a backup to a directory, a second run that sends nothing new, and a //! restore into a fresh hub that gives back the same repository, byte for //! byte; and an owner's backup never holding another's repositories. use oxy_core::repo::Repo; use oxygenhub::backups::{self, Destination, Directory, Setting}; use oxygenhub::config::Config; use oxygenhub::repos::RepoName; use oxygenhub::{db, Shared}; use std::path::Path; use std::sync::Arc; fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } struct Hub { app: Shared, base: String, _dir: tempfile::TempDir, } async fn hub(backups: &Path) -> Hub { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); config.auth.push_tokens = vec!["site".into()]; config.backups.directories = vec![backups.to_path_buf()]; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); config.server.base_url = format!("http://127.0.0.1:{port}"); let app = oxygenhub::build(config).await.unwrap(); let router = oxygenhub::router(app.clone()); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } } fn write(root: &Path, rel: &str, content: &str) { let p = root.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, content).unwrap(); } async fn create(app: &Shared, owner: &str, name: &str, visibility: hub_api::Visibility) { app.repos.create(&RepoName::parse(&format!("{owner}/{name}")).unwrap(), None).unwrap(); db::repos::create(&app.db, owner, name, "a demo", visibility).await.unwrap(); } fn push(url: &str, work: &Path, files: &[(&str, &str)]) { let mut c = Repo::init(work).unwrap(); c.config.set("remote.origin.url", url).unwrap(); c.config.set("remote.origin.token", "site").unwrap(); c.save_config().unwrap(); for (f, t) in files { write(work, f, t); } c.snapshot("t").unwrap(); c.commit("Some work", &[]).unwrap(); assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); } /// Every file under `dir`, with its bytes. fn files(dir: &Path) -> Vec<(String, Vec)> { let mut out = Vec::new(); let mut stack = vec![dir.to_path_buf()]; while let Some(d) = stack.pop() { for e in std::fs::read_dir(&d).unwrap().flatten() { let p = e.path(); if p.is_dir() { stack.push(p); } else { out.push((p.strip_prefix(dir).unwrap().to_string_lossy().into_owned(), std::fs::read(&p).unwrap())); } } } out.sort(); out } #[tokio::test(flavor = "multi_thread")] async fn a_backup_restores_the_same_repository_and_sends_only_what_changed() { let store = tempfile::tempdir().unwrap(); let h = hub(store.path()).await; db::accounts::create_account(&h.app.db, "andy", "a@example.com", "x", "salt", false).await.unwrap(); db::accounts::create_account(&h.app.db, "bob", "b@example.com", "x", "salt", false).await.unwrap(); create(&h.app, "andy", "demo", hub_api::Visibility::Private).await; create(&h.app, "bob", "other", hub_api::Visibility::Public).await; let work = tempfile::tempdir().unwrap(); push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n"), ("src/main.rs", "fn main() {}\n")]); let other = tempfile::tempdir().unwrap(); push(&format!("{}/bob/other", h.base), other.path(), &[("x.txt", "bob's\n")]); // Andy backs up to the directory the hub allows. let target = store.path().display().to_string(); let mut s = Setting::off("andy", "user"); s.every = "hourly".into(); s.target = target.clone(); backups::save(&h.app.db, &s).await.unwrap(); let first = backups::run(&h.app, "andy", "now").await.unwrap(); assert_eq!((first.status.as_str(), first.repos), ("ok", 1), "{}", first.detail); assert!(first.sent > 0 && first.bytes > 0); let dest = Directory { root: store.path().join("andy") }; assert!(dest.exists(&format!("runs/{}/DONE", first.at)).unwrap()); let records: serde_json::Value = serde_json::from_slice(&dest.get(&format!("runs/{}/records/demo.json", first.at)).unwrap()).unwrap(); assert_eq!(records["repositories"][0]["visibility"], "private"); let text = String::from_utf8_lossy(&dest.get(&format!("runs/{}/records/demo.json", first.at)).unwrap()).into_owned(); assert!(!text.contains("bob/other") && !text.contains("\"other\""), "nothing of another owner"); assert!(!store.path().join("bob").exists(), "bob set up no backup"); // Nothing changed: the next run sends nothing new. tokio::time::sleep(std::time::Duration::from_millis(1100)).await; let second = backups::run(&h.app, "andy", "now").await.unwrap(); assert_eq!((second.status.as_str(), second.sent), ("ok", 0), "{}", second.detail); // A fresh hub, restored from the backup, has the same repository. let empty = tempfile::tempdir().unwrap(); let fresh = hub(empty.path()).await; let dest: Arc = Arc::new(Directory { root: store.path().join("andy") }); let done = backups::restore_into_hub(&fresh.app, dest, None).await.unwrap(); assert_eq!(done, vec!["andy/demo (repos)"]); let name = RepoName::parse("andy/demo").unwrap(); assert_eq!(files(&fresh.app.repos.dir(&name)), files(&h.app.repos.dir(&name)), "byte for byte"); let row = db::repos::get(&fresh.app.db, "andy", "demo").await.unwrap().expect("listed on the hub"); assert_eq!((row.visibility.as_str(), row.description.as_str()), ("private", "a demo")); let (a, b) = (h.app.repos.open(&name).unwrap(), fresh.app.repos.open(&name).unwrap()); assert_eq!( a.bookmarks().unwrap().iter().map(|b| b.heads.clone()).collect::>(), b.bookmarks().unwrap().iter().map(|b| b.heads.clone()).collect::>() ); // A directory the hub does not allow is refused. let mut bad = Setting::off("andy", "user"); bad.every = "hourly".into(); bad.target = "/tmp/somewhere-else".into(); backups::save(&h.app.db, &bad).await.unwrap(); let refused = backups::run(&h.app, "andy", "now").await.unwrap(); assert_eq!(refused.status, "failed"); assert!(refused.detail.contains("not a backup directory this hub allows"), "{}", refused.detail); } #[tokio::test(flavor = "multi_thread")] async fn backups_fall_due_on_their_schedule() { let store = tempfile::tempdir().unwrap(); let h = hub(store.path()).await; let mut s = Setting::off("andy", "user"); s.target = store.path().display().to_string(); backups::save(&h.app.db, &s).await.unwrap(); assert!(backups::due(&h.app.db).await.unwrap().is_empty(), "off is never due"); s.every = "hourly".into(); backups::save(&h.app.db, &s).await.unwrap(); assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1, "never run: due"); sqlx::query("INSERT INTO backup_runs (owner, started_ms, status) VALUES ('andy', ?, 'ok')") .bind(db::now_ms()) .execute(&h.app.db) .await .unwrap(); assert!(backups::due(&h.app.db).await.unwrap().is_empty(), "ran just now"); }