OXYGENHUB
◆ v0.9.4 oxygenhub / crates / hub / tests / backups.rs
47.8 KiB · Rust · last changed Drops are backed up and restored as drops (refs #280) 15 days ago source blame history raw
1
//! Backups (docs/backups.md, oxygenhub#169), end to end: a real `oxy` push,
2
//! a backup to a directory, a second run that sends nothing new, and a
3
//! restore into a fresh hub that gives back the same repository, byte for
4
//! byte; and an owner's backup never holding another's repositories.
5
6
use oxy_core::repo::Repo;
7
use oxygenhub::backups::{self, Destination, Directory, Setting};
8
use oxygenhub::config::Config;
9
use oxygenhub::repos::RepoName;
10
use oxygenhub::{db, Shared};
11
use std::path::Path;
12
use std::sync::Arc;
13
14
fn hermetic() {
15
    static ONCE: std::sync::Once = std::sync::Once::new();
16
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
17
    ONCE.call_once(|| {
18
        let d = tempfile::tempdir().unwrap();
19
        std::env::set_var("OXY_HOME", d.path());
20
        DIR.set(d).ok();
21
    });
22
}
23
24
struct Hub {
25
    app: Shared,
26
    base: String,
27
    _dir: tempfile::TempDir,
28
}
29
30
async fn hub(backups: &Path) -> Hub {
31
    hub_with(backups, None).await
32
}
33
34
async fn hub_with(backups: &Path, google: Option<&str>) -> Hub {
35
    hermetic();
36
    let dir = tempfile::tempdir().unwrap();
37
    let mut config = Config::load(dir.path()).unwrap();
38
    // The first account's setup code (oxygenhub#245).
39
    config.registration.setup_code = "test-setup".into();
40
    config.auth.push_tokens = vec!["site".into()];
41
    config.backups.directories = vec![backups.to_path_buf()];
42
    if let Some(g) = google {
43
        config.backups.sts_url = g.to_string();
44
        config.backups.storage_url = g.to_string();
45
    }
46
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
47
    let port = listener.local_addr().unwrap().port();
48
    config.server.base_url = format!("http://127.0.0.1:{port}");
49
    let app = oxygenhub::build(config).await.unwrap();
50
    let router = oxygenhub::router(app.clone());
51
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
52
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
53
}
54
55
fn write(root: &Path, rel: &str, content: &str) {
56
    let p = root.join(rel);
57
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
58
    std::fs::write(p, content).unwrap();
59
}
60
61
async fn create(app: &Shared, owner: &str, name: &str, visibility: hub_api::Visibility) {
62
    app.repos.create(&RepoName::parse(&format!("{owner}/{name}")).unwrap(), None).unwrap();
63
    db::repos::create(&app.db, owner, name, "a demo", visibility).await.unwrap();
64
}
65
66
fn push(url: &str, work: &Path, files: &[(&str, &str)]) {
67
    let mut c = Repo::init(work).unwrap();
68
    c.config.set("remote.origin.url", url).unwrap();
69
    c.config.set("remote.origin.token", "site").unwrap();
70
    c.save_config().unwrap();
71
    for (f, t) in files {
72
        write(work, f, t);
73
    }
74
    c.snapshot("t").unwrap();
75
    c.commit("Some work", &[]).unwrap();
76
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
77
}
78
79
/// Every file under `dir`, with its bytes.
80
fn files(dir: &Path) -> Vec<(String, Vec<u8>)> {
81
    let mut out = Vec::new();
82
    let mut stack = vec![dir.to_path_buf()];
83
    while let Some(d) = stack.pop() {
84
        for e in std::fs::read_dir(&d).unwrap().flatten() {
85
            let p = e.path();
86
            if p.is_dir() {
87
                stack.push(p);
88
            } else {
89
                out.push((p.strip_prefix(dir).unwrap().to_string_lossy().into_owned(), std::fs::read(&p).unwrap()));
90
            }
91
        }
92
    }
93
    out.sort();
94
    out
95
}
96
97
#[tokio::test(flavor = "multi_thread")]
98
async fn a_backup_restores_the_same_repository_and_sends_only_what_changed() {
99
    let store = tempfile::tempdir().unwrap();
100
    let h = hub(store.path()).await;
101
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
102
    db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap();
103
    create(&h.app, "andy", "demo", hub_api::Visibility::Private).await;
104
    create(&h.app, "bob", "other", hub_api::Visibility::Public).await;
105
    let work = tempfile::tempdir().unwrap();
106
    push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n"), ("src/main.rs", "fn main() {}\n")]);
107
    let other = tempfile::tempdir().unwrap();
108
    push(&format!("{}/bob/other", h.base), other.path(), &[("x.txt", "bob's\n")]);
109
110
    // Andy backs up to the directory the hub allows.
111
    let target = store.path().display().to_string();
112
    let mut s = Setting::off("andy", "user");
113
    s.every = "hourly".into();
114
    s.target = target.clone();
115
    backups::save(&h.app.db, &s).await.unwrap();
116
    let first = backups::run(&h.app, "andy", "now").await.unwrap();
117
    assert_eq!((first.status.as_str(), first.repos), ("ok", 1), "{}", first.detail);
118
    assert!(first.sent > 0 && first.bytes > 0);
119
    let dest = Directory { root: store.path().join("andy") };
120
    assert!(dest.exists(&format!("runs/{}/DONE", first.at)).unwrap());
121
    let records: serde_json::Value = serde_json::from_slice(&dest.get(&format!("runs/{}/records/demo.json", first.at)).unwrap()).unwrap();
122
    assert_eq!(records["repositories"][0]["visibility"], "private");
123
    let text = String::from_utf8_lossy(&dest.get(&format!("runs/{}/records/demo.json", first.at)).unwrap()).into_owned();
124
    assert!(!text.contains("bob/other") && !text.contains("\"other\""), "nothing of another owner");
125
    assert!(!store.path().join("bob").exists(), "bob set up no backup");
126
127
    // Nothing changed: the next run sends nothing new.
128
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
129
    let second = backups::run(&h.app, "andy", "now").await.unwrap();
130
    assert_eq!((second.status.as_str(), second.sent), ("ok", 0), "{}", second.detail);
131
132
    // A fresh hub, restored from the backup, has the same repository.
133
    let empty = tempfile::tempdir().unwrap();
134
    let fresh = hub(empty.path()).await;
135
    let dest: Arc<dyn Destination> = Arc::new(Directory { root: store.path().join("andy") });
136
    let done = backups::restore_into_hub(&fresh.app, dest, None).await.unwrap();
137
    assert_eq!(done, vec!["andy/demo (repos)"]);
138
    let name = RepoName::parse("andy/demo").unwrap();
139
    assert_eq!(files(&fresh.app.repos.dir(&name)), files(&h.app.repos.dir(&name)), "byte for byte");
140
    let row = db::repos::get(&fresh.app.db, "andy", "demo").await.unwrap().expect("listed on the hub");
141
    assert_eq!((row.visibility.as_str(), row.description.as_str()), ("private", "a demo"));
142
    let (a, b) = (h.app.repos.open(&name).unwrap(), fresh.app.repos.open(&name).unwrap());
143
    assert_eq!(
144
        a.bookmarks().unwrap().iter().map(|b| b.heads.clone()).collect::<Vec<_>>(),
145
        b.bookmarks().unwrap().iter().map(|b| b.heads.clone()).collect::<Vec<_>>()
146
    );
147
148
    // A directory the hub does not allow is refused.
149
    let mut bad = Setting::off("andy", "user");
150
    bad.every = "hourly".into();
151
    bad.target = "/tmp/somewhere-else".into();
152
    backups::save(&h.app.db, &bad).await.unwrap();
153
    let refused = backups::run(&h.app, "andy", "now").await.unwrap();
154
    assert_eq!(refused.status, "failed");
155
    assert!(refused.detail.contains("not a backup directory this hub allows"), "{}", refused.detail);
156
}
157
158
/// oxygenhub#280: an owner's drops are in its backups, and come back as
159
/// drops: their kind, secrecy and title with their store.
160
#[tokio::test(flavor = "multi_thread")]
161
async fn drops_are_backed_up_and_restored_as_drops() {
162
    let store = tempfile::tempdir().unwrap();
163
    let h = hub(store.path()).await;
164
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
165
    create(&h.app, "andy", "demo", hub_api::Visibility::Public).await;
166
    let id = "0123456789abcdef01234567";
167
    let name = RepoName::drop("andy", id).unwrap();
168
    h.app.repos.create(&name, None).unwrap();
169
    db::repos::create_drop(&h.app.db, "andy", id, "Notes", "a secret drop", hub_api::Visibility::Private).await.unwrap();
170
171
    let mut s = Setting::off("andy", "user");
172
    s.every = "hourly".into();
173
    s.target = store.path().display().to_string();
174
    backups::save(&h.app.db, &s).await.unwrap();
175
    let run = backups::run(&h.app, "andy", "now").await.unwrap();
176
    assert_eq!(run.status, "ok", "{}", run.detail);
177
    let dest = Directory { root: store.path().join("andy") };
178
    let repos = dest.list(&format!("runs/{}/repos", run.at)).unwrap();
179
    assert!(repos.contains(&format!("repos-drops~{id}.json")), "the drop's manifest, one level deep: {repos:?}");
180
    assert!(dest.list(&format!("runs/{}/records", run.at)).unwrap().contains(&format!("drops~{id}.json")));
181
    let (overview, _) = backups::overview(&h.app.db).await.unwrap();
182
    assert_eq!(overview[0].repos, 2, "the repository and the drop");
183
184
    let empty = tempfile::tempdir().unwrap();
185
    let fresh = hub(empty.path()).await;
186
    db::accounts::create_account(&fresh.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
187
    let dest: Arc<dyn Destination> = Arc::new(Directory { root: store.path().join("andy") });
188
    let done = backups::restore_into_hub(&fresh.app, dest, None).await.unwrap();
189
    assert!(done.contains(&format!("andy/drops/{id} (repos)")), "{done:?}");
190
    assert!(fresh.app.repos.exists(&name), "the drop's store");
191
    let back = db::repos::drop_by_id(&fresh.app.db, id).await.unwrap().expect("the drop's row");
192
    assert!(back.is_drop() && back.visibility == "private" && back.title == "Notes", "{back:?}");
193
    assert!(db::repos::list(&fresh.app.db).await.unwrap().iter().all(|r| !r.is_drop()), "still no drop among repositories");
194
}
195
196
#[tokio::test(flavor = "multi_thread")]
197
async fn backups_fall_due_on_their_schedule() {
198
    let store = tempfile::tempdir().unwrap();
199
    let h = hub(store.path()).await;
200
    let mut s = Setting::off("andy", "user");
201
    s.target = store.path().display().to_string();
202
    backups::save(&h.app.db, &s).await.unwrap();
203
    assert!(backups::due(&h.app.db).await.unwrap().is_empty(), "off is never due");
204
    s.every = "hourly".into();
205
    backups::save(&h.app.db, &s).await.unwrap();
206
    assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1, "never run: due");
207
    sqlx::query("INSERT INTO backup_runs (owner, started_ms, status) VALUES ('andy', $1, 'ok')")
208
        .bind(db::now_ms())
209
        .execute(&h.app.db)
210
        .await
211
        .unwrap();
212
    assert!(backups::due(&h.app.db).await.unwrap().is_empty(), "ran just now");
213
}
214
215
// ------------------------------------------------------------ a fake Google
216
217
/// What the fake has seen and holds.
218
#[derive(Default)]
219
struct Google {
220
    /// Objects by bucket and name.
221
    objects: std::collections::BTreeMap<(String, String), Vec<u8>>,
222
    /// The subjects and audiences of the tokens exchanged.
223
    exchanged: Vec<(String, String)>,
224
}
225
226
type Shared2 = Arc<std::sync::Mutex<Google>>;
227
228
fn decode(s: &str) -> String {
229
    let b = s.as_bytes();
230
    let mut out = Vec::new();
231
    let mut i = 0;
232
    while i < b.len() {
233
        if b[i] == b'%' && i + 2 < b.len() {
234
            out.push(u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap());
235
            i += 3;
236
        } else {
237
            out.push(b[i]);
238
            i += 1;
239
        }
240
    }
241
    String::from_utf8(out).unwrap()
242
}
243
244
fn query(uri: &axum::http::Uri, key: &str) -> Option<String> {
245
    uri.query()?.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| decode(v)))
246
}
247
248
/// Google's STS and Cloud Storage, as much as backups use: a token
249
/// exchange that admits `backup:` subjects for the provider's audience,
250
/// and buckets that admit only their owner's token (bucket `OWNER-backup`).
251
async fn fake_google() -> (String, Shared2) {
252
    use axum::extract::{Request, State};
253
    use axum::http::StatusCode;
254
    use axum::response::IntoResponse;
255
    let state: Shared2 = Default::default();
256
    async fn handle(State(g): State<Shared2>, req: Request) -> axum::response::Response {
257
        let (parts, body) = req.into_parts();
258
        let bytes = http_body_util::BodyExt::collect(body).await.unwrap().to_bytes();
259
        let path = parts.uri.path().to_string();
260
        if path == "/v1/token" {
261
            let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
262
            let jwt = v["subjectToken"].as_str().unwrap();
263
            let payload = jwt.split('.').nth(1).unwrap();
264
            let claims: serde_json::Value = serde_json::from_slice(&base64url(payload)).unwrap();
265
            let (sub, aud) = (claims["sub"].as_str().unwrap().to_string(), claims["aud"].as_str().unwrap().to_string());
266
            let provider = v["audience"].as_str().unwrap();
267
            g.lock().unwrap().exchanged.push((sub.clone(), aud.clone()));
268
            if !sub.starts_with("backup:") || aud != format!("https:{provider}") {
269
                return (
270
                    StatusCode::BAD_REQUEST,
271
                    axum::Json(serde_json::json!({"error": "invalid_grant", "error_description": "not admitted"})),
272
                )
273
                    .into_response();
274
            }
275
            return axum::Json(serde_json::json!({ "access_token": format!("tok-{sub}"), "expires_in": 3600 })).into_response();
276
        }
277
        let auth = parts.headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
278
        let segs: Vec<&str> = path.split('/').collect();
279
        // /storage/v1/b/{bucket}/o[/{name}]  or  /upload/storage/v1/b/{bucket}/o
280
        let upload = segs.get(1) == Some(&"upload");
281
        let at = if upload { 5 } else { 4 };
282
        let bucket = segs.get(at).unwrap_or(&"").to_string();
283
        let owner = bucket.trim_end_matches("-backup");
284
        if auth != format!("Bearer tok-backup:{owner}") {
285
            return (StatusCode::FORBIDDEN, "caller does not have storage.objects access").into_response();
286
        }
287
        let mut g = g.lock().unwrap();
288
        if upload {
289
            // As Google does: an upload must say its length, an empty one too.
290
            if !parts.headers.contains_key("content-length") {
291
                return (StatusCode::LENGTH_REQUIRED, "Error 411 (Length Required)").into_response();
292
            }
293
            let name = query(&parts.uri, "name").unwrap();
294
            g.objects.insert((bucket, name), bytes.to_vec());
295
            return axum::Json(serde_json::json!({})).into_response();
296
        }
297
        let name = segs.get(at + 2).map(|n| decode(n));
298
        match (parts.method.as_str(), name) {
299
            ("GET", Some(n)) => match g.objects.get(&(bucket, n.clone())) {
300
                Some(b) if query(&parts.uri, "alt").as_deref() == Some("media") => b.clone().into_response(),
301
                Some(_) => axum::Json(serde_json::json!({ "name": n })).into_response(),
302
                None => StatusCode::NOT_FOUND.into_response(),
303
            },
304
            ("DELETE", Some(n)) => {
305
                g.objects.remove(&(bucket, n));
306
                StatusCode::NO_CONTENT.into_response()
307
            }
308
            ("GET", None) => {
309
                let prefix = query(&parts.uri, "prefix").unwrap_or_default();
310
                let delimited = query(&parts.uri, "delimiter").is_some();
311
                let (mut items, mut prefixes) = (Vec::new(), std::collections::BTreeSet::new());
312
                for (b, n) in g.objects.keys() {
313
                    if *b != bucket || !n.starts_with(&prefix) {
314
                        continue;
315
                    }
316
                    let rest = &n[prefix.len()..];
317
                    match rest.find('/') {
318
                        Some(i) if delimited => {
319
                            prefixes.insert(format!("{prefix}{}", &rest[..=i]));
320
                        }
321
                        _ => items.push(serde_json::json!({ "name": n })),
322
                    }
323
                }
324
                axum::Json(serde_json::json!({ "items": items, "prefixes": prefixes })).into_response()
325
            }
326
            _ => StatusCode::METHOD_NOT_ALLOWED.into_response(),
327
        }
328
    }
329
    let app = axum::Router::new().fallback(handle).with_state(state.clone());
330
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
331
    let port = listener.local_addr().unwrap().port();
332
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
333
    (format!("http://127.0.0.1:{port}"), state)
334
}
335
336
fn base64url(s: &str) -> Vec<u8> {
337
    let table = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
338
    let mut bits = 0u32;
339
    let mut n = 0;
340
    let mut out = Vec::new();
341
    for c in s.bytes() {
342
        let v = table.iter().position(|t| *t == c).unwrap() as u32;
343
        bits = (bits << 6) | v;
344
        n += 6;
345
        if n >= 8 {
346
            n -= 8;
347
            out.push((bits >> n) as u8);
348
            bits &= (1 << n) - 1;
349
        }
350
    }
351
    out
352
}
353
354
const PROVIDER: &str = "//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io";
355
356
fn gcs(owner: &str, bucket: &str) -> Setting {
357
    let mut s = Setting::off(owner, "user");
358
    s.every = "hourly".into();
359
    s.destination = "gcs".into();
360
    s.target = bucket.into();
361
    s.provider = PROVIDER.into();
362
    s
363
}
364
365
#[tokio::test(flavor = "multi_thread")]
366
async fn a_gcs_backup_needs_no_key_and_restores_the_same_repository() {
367
    let (google, seen) = fake_google().await;
368
    let store = tempfile::tempdir().unwrap();
369
    let h = hub_with(store.path(), Some(&google)).await;
370
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
371
    db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap();
372
    create(&h.app, "andy", "demo", hub_api::Visibility::Public).await;
373
    let work = tempfile::tempdir().unwrap();
374
    push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n")]);
375
    // A store holds empty files too (as oxygenhub.io's did, 2026-10-14):
376
    // they go up with a length of 0.
377
    std::fs::write(h.app.repos.dir(&RepoName::parse("andy/demo").unwrap()).join(".oxy/empty-marker"), b"").unwrap();
378
379
    backups::save(&h.app.db, &gcs("andy", "andy-backup")).await.unwrap();
380
    let first = backups::run(&h.app, "andy", "now").await.unwrap();
381
    assert_eq!((first.status.as_str(), first.repos), ("ok", 1), "{}", first.detail);
382
    {
383
        let g = seen.lock().unwrap();
384
        assert!(g.objects.keys().any(|(b, n)| b == "andy-backup" && n == &format!("runs/{}/DONE", first.at)));
385
        assert!(g.objects.keys().all(|(b, _)| b == "andy-backup"));
386
        let (sub, aud) = g.exchanged.first().unwrap();
387
        assert_eq!(
388
            (sub.as_str(), aud.as_str()),
389
            ("backup:andy", format!("https:{PROVIDER}").as_str()),
390
            "the hub signs in as the owner, for the provider"
391
        );
392
    }
393
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
394
    let second = backups::run(&h.app, "andy", "now").await.unwrap();
395
    assert_eq!((second.status.as_str(), second.sent), ("ok", 0), "{}", second.detail);
396
    // The probe writes, reads and removes; the bucket is as it was.
397
    let before = seen.lock().unwrap().objects.len();
398
    let dest = backups::destination(&h.app, &gcs("andy", "andy-backup")).unwrap();
399
    tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap().unwrap();
400
    assert_eq!(seen.lock().unwrap().objects.len(), before);
401
402
    // Restored from the bucket into a fresh hub: the same repository.
403
    let empty = tempfile::tempdir().unwrap();
404
    let fresh = hub_with(empty.path(), Some(&google)).await;
405
    let dest: Arc<dyn Destination> = backups::destination(&fresh.app, &gcs("andy", "andy-backup")).unwrap().into();
406
    assert_eq!(backups::restore_into_hub(&fresh.app, dest, None).await.unwrap(), vec!["andy/demo (repos)"]);
407
    let name = RepoName::parse("andy/demo").unwrap();
408
    assert_eq!(files(&fresh.app.repos.dir(&name)), files(&h.app.repos.dir(&name)), "byte for byte");
409
410
    // Bob cannot write Andy's bucket: Google refuses his token, and the
411
    // run says what to grant.
412
    backups::save(&h.app.db, &gcs("bob", "andy-backup")).await.unwrap();
413
    let refused = backups::run(&h.app, "bob", "now").await.unwrap();
414
    assert_eq!(refused.status, "failed");
415
    assert!(refused.detail.contains("roles/storage.objectAdmin") && refused.detail.contains("backup:bob"), "{}", refused.detail);
416
}
417
418
/// docs/backups.md §7 (BP1): a restore brings back the records as well as
419
/// the stores: issues with their comments, labels, milestone and
420
/// assignees, proposals with their reviews and events, releases with their
421
/// asset files, and webhooks without their secrets, every id mapped and
422
/// every account found again by name; what this hub lacks is said.
423
#[tokio::test(flavor = "multi_thread")]
424
async fn a_restore_brings_back_issues_proposals_and_releases() {
425
    let store = tempfile::tempdir().unwrap();
426
    let h = hub(store.path()).await;
427
    let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
428
    let bob = db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap();
429
    create(&h.app, "andy", "demo", hub_api::Visibility::Public).await;
430
    let work = tempfile::tempdir().unwrap();
431
    push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n")]);
432
    let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap();
433
    db::repos::set_diffusion(&h.app.db, row.id, "green").await.unwrap();
434
    // Ids on the first hub start further on than on the fresh one.
435
    for n in 0..3 {
436
        db::issues::upsert_label(&h.app.db, row.id, &format!("spare{n}"), "#000000", "").await.unwrap();
437
    }
438
    let bug = db::issues::upsert_label(&h.app.db, row.id, "bug", "#d73a4a", "Something is wrong").await.unwrap();
439
    let m = db::issues::upsert_milestone(&h.app.db, row.id, "1.0", "the first", None).await.unwrap();
440
    let issue = db::issues::create(
441
        &h.app.db,
442
        db::issues::NewIssue { repo_id: row.id, title: "Sprites flicker", body: "on level two", author_id: Some(andy.id), author: "andy" },
443
    )
444
    .await
445
    .unwrap();
446
    db::issues::add_comment(&h.app.db, issue.id, Some(bob.id), "bob", "seen it too").await.unwrap();
447
    db::issues::set_issue_label(&h.app.db, issue.id, bug.id, true).await.unwrap();
448
    db::issues::set_milestone(&h.app.db, issue.id, Some(m.id)).await.unwrap();
449
    db::issues::set_assignee(&h.app.db, issue.id, andy.id, true).await.unwrap();
450
    db::issues::set_assignee(&h.app.db, issue.id, bob.id, true).await.unwrap();
451
    let p = db::proposals::create(
452
        &h.app.db,
453
        db::proposals::NewProposal {
454
            repo_id: row.id,
455
            title: "Double-buffer the sprites",
456
            body: "fixes the flicker",
457
            author_id: Some(bob.id),
458
            author: "bob",
459
            draft: false,
460
            target: "main",
461
            source_kind: "bookmark",
462
            source_repo_id: Some(row.id),
463
            source_bookmark: "fix",
464
            source_changes: "",
465
        },
466
    )
467
    .await
468
    .unwrap();
469
    db::proposals::add_event(&h.app.db, p.id, "bob", "opened", "").await.unwrap();
470
    db::proposals::add_review(&h.app.db, p.id, andy.id, "andy", "approve", "nice").await.unwrap();
471
    let release = db::releases::create(&h.app.db, row.id, "v1.0", "One", "notes", false, Some((andy.id, "andy"))).await.unwrap();
472
    let asset = b"a game image".to_vec();
473
    let hash = blake3::hash(&asset).to_hex().to_string();
474
    let path = h.app.config.data.join("assets").join(&hash[..2]).join(&hash);
475
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
476
    std::fs::write(&path, &asset).unwrap();
477
    db::releases::add_asset(&h.app.db, release.id, "game.prg", asset.len() as i64, &hash, "application/octet-stream").await.unwrap();
478
    db::hooks::create_hook(&h.app.db, Some(row.id), None, "https://example.com/hook", "s3cret-value", "push").await.unwrap();
479
480
    let mut s = Setting::off("andy", "user");
481
    s.every = "hourly".into();
482
    s.target = store.path().display().to_string();
483
    backups::save(&h.app.db, &s).await.unwrap();
484
    let run = backups::run(&h.app, "andy", "now").await.unwrap();
485
    assert_eq!(run.status, "ok", "{}", run.detail);
486
    let dest = Directory { root: store.path().join("andy") };
487
    let text = String::from_utf8_lossy(&dest.get(&format!("runs/{}/records/demo.json", run.at)).unwrap()).into_owned();
488
    assert!(!text.contains("s3cret-value"), "never a secret");
489
    assert!(text.contains("\"_names\"") && text.contains("\"bob\""), "accounts by name");
490
491
    // A fresh hub that knows andy but not bob.
492
    let empty = tempfile::tempdir().unwrap();
493
    let fresh = hub(empty.path()).await;
494
    let andy2 = db::accounts::create_account(&fresh.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
495
    let dest: Arc<dyn Destination> = Arc::new(Directory { root: store.path().join("andy") });
496
    let done = backups::restore_into_hub(&fresh.app, dest, None).await.unwrap();
497
    assert_eq!(done[0], "andy/demo (repos)");
498
    let notes = done[1..].join("\n");
499
    assert!(notes.contains("webhook https://example.com/hook: enter its secret again"), "{notes}");
500
    assert!(notes.contains("issue_assignees: 1 row(s) left out"), "bob is not on this hub: {notes}");
501
    let r2 = db::repos::get(&fresh.app.db, "andy", "demo").await.unwrap().unwrap();
502
    assert_eq!(r2.diffusion, "green", "settings come back");
503
    // The issue, its comment, label, milestone and the assignee this hub has.
504
    let issues: Vec<(i64, i64, String, Option<i64>)> = sqlx::query_as("SELECT id, number, title, author_id FROM issues WHERE repo_id = $1")
505
        .bind(r2.id)
506
        .fetch_all(&fresh.app.db)
507
        .await
508
        .unwrap();
509
    assert_eq!(issues.len(), 1);
510
    let (iid, number, title, author) = issues[0].clone();
511
    assert_eq!((number, title.as_str(), author), (issue.number, "Sprites flicker", Some(andy2.id)));
512
    let comments = db::issues::comments(&fresh.app.db, iid).await.unwrap();
513
    assert_eq!(comments.len(), 1);
514
    assert_eq!((comments[0].author.as_str(), comments[0].body.as_str()), ("bob", "seen it too"));
515
    let labels: Vec<String> =
516
        sqlx::query_scalar("SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = $1")
517
            .bind(iid)
518
            .fetch_all(&fresh.app.db)
519
            .await
520
            .unwrap();
521
    assert_eq!(labels, vec!["bug"], "the label, its id mapped");
522
    let milestone: Option<String> =
523
        sqlx::query_scalar("SELECT m.title FROM issues i JOIN milestones m ON m.id = i.milestone_id WHERE i.id = $1")
524
            .bind(iid)
525
            .fetch_one(&fresh.app.db)
526
            .await
527
            .unwrap();
528
    assert_eq!(milestone.as_deref(), Some("1.0"));
529
    assert_eq!(db::issues::assignees(&fresh.app.db, iid).await.unwrap(), vec!["andy"]);
530
    // The proposal, its review and its timeline.
531
    let p2 = db::proposals::get(&fresh.app.db, r2.id, p.number).await.unwrap().expect("the proposal");
532
    assert_eq!((p2.title.as_str(), p2.author.as_str(), p2.author_id), ("Double-buffer the sprites", "bob", None));
533
    let reviews: Vec<(String, String)> = sqlx::query_as("SELECT author, verdict FROM proposal_reviews WHERE proposal_id = $1")
534
        .bind(p2.id)
535
        .fetch_all(&fresh.app.db)
536
        .await
537
        .unwrap();
538
    assert_eq!(reviews, vec![("andy".to_string(), "approve".to_string())]);
539
    assert!(db::proposals::events(&fresh.app.db, p2.id).await.unwrap().iter().any(|e| e.event == "opened"));
540
    // The release and its asset's bytes.
541
    let rel = db::releases::get(&fresh.app.db, r2.id, "v1.0").await.unwrap().expect("the release");
542
    let assets = db::releases::assets(&fresh.app.db, rel.id).await.unwrap();
543
    assert_eq!(assets.len(), 1);
544
    assert_eq!(std::fs::read(fresh.app.config.data.join("assets").join(&hash[..2]).join(&hash)).unwrap(), asset);
545
    // Search finds what came back.
546
    let hits = db::notify::search(&fresh.app.db, "flicker", Some(r2.id), 10).await.unwrap();
547
    assert!(hits.iter().any(|h| h.kind == "issue" && h.target_id == iid), "{:?}", hits.iter().map(|h| &h.title).collect::<Vec<_>>());
548
    // New issues number on from where the old hub was.
549
    let next = db::issues::create(
550
        &fresh.app.db,
551
        db::issues::NewIssue { repo_id: r2.id, title: "After", body: "", author_id: Some(andy2.id), author: "andy" },
552
    )
553
    .await
554
    .unwrap();
555
    assert!(next.number > p.number, "{} after {}", next.number, p.number);
556
}
557
558
/// docs/backups.md §5 (BP3): a repository left out is in no run; one with
559
/// its own schedule makes its owner's backup run at least that often, and
560
/// alone when its owner's is off; a run by hand takes all but those left
561
/// out.
562
#[tokio::test(flavor = "multi_thread")]
563
async fn repositories_choose_their_own_backup() {
564
    let store = tempfile::tempdir().unwrap();
565
    let h = hub(store.path()).await;
566
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
567
    for n in ["game", "notes", "secret"] {
568
        create(&h.app, "andy", n, hub_api::Visibility::Public).await;
569
        let work = tempfile::tempdir().unwrap();
570
        push(&format!("{}/andy/{n}", h.base), work.path(), &[("README.md", n)]);
571
    }
572
    let id = |n: &'static str| {
573
        let db = h.app.db.clone();
574
        async move { db::repos::get(&db, "andy", n).await.unwrap().unwrap().id }
575
    };
576
    db::repos::set_backup(&h.app.db, id("secret").await, "off").await.unwrap();
577
    let mut s = Setting::off("andy", "user");
578
    s.every = "daily".into();
579
    s.target = store.path().display().to_string();
580
    backups::save(&h.app.db, &s).await.unwrap();
581
    let names = |at: String| {
582
        let dest = Directory { root: store.path().join("andy") };
583
        let mut v = dest.list(&format!("runs/{at}/records")).unwrap();
584
        v.sort();
585
        v
586
    };
587
    let first = backups::run(&h.app, "andy", "schedule").await.unwrap();
588
    assert_eq!((first.status.as_str(), first.repos), ("ok", 2), "{}", first.detail);
589
    assert_eq!(names(first.at.clone()), vec!["game.json", "notes.json"], "the one left out is not in it");
590
    // Daily, and it just ran: not due, until a repository asks for hourly.
591
    assert!(backups::due(&h.app.db).await.unwrap().is_empty());
592
    db::repos::set_backup(&h.app.db, id("game").await, "hourly").await.unwrap();
593
    sqlx::query("UPDATE backup_runs SET started_ms = started_ms - 2 * 3600 * 1000").execute(&h.app.db).await.unwrap();
594
    assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1, "hourly for the game");
595
    // The owner's schedule off: only the game, which asks for its own.
596
    s.every = "off".into();
597
    backups::save(&h.app.db, &s).await.unwrap();
598
    assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1);
599
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
600
    let second = backups::run(&h.app, "andy", "schedule").await.unwrap();
601
    assert_eq!(names(second.at.clone()), vec!["game.json"]);
602
    // By hand: everything but the one left out.
603
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
604
    let third = backups::run(&h.app, "andy", "now").await.unwrap();
605
    assert_eq!(names(third.at.clone()), vec!["game.json", "notes.json"]);
606
    // Admin › Backups counts what the next scheduled run covers.
607
    let (owners, uncovered) = backups::overview(&h.app.db).await.unwrap();
608
    assert_eq!((owners[0].repos, uncovered.len()), (1, 0));
609
}
610
611
/// AWS STS and S3, as much as backups use (path style, under `/s3`): an
612
/// AssumeRoleWithWebIdentity that admits `backup:` subjects with audience
613
/// `sts.amazonaws.com`, and buckets (`OWNER-backup`) that check every
614
/// request's Signature Version 4 and admit only their owner's session.
615
async fn fake_aws() -> (String, Shared2) {
616
    use axum::extract::{Request, State};
617
    use axum::http::StatusCode;
618
    use axum::response::IntoResponse;
619
    let state: Shared2 = Default::default();
620
    async fn handle(State(g): State<Shared2>, req: Request) -> axum::response::Response {
621
        let (parts, body) = req.into_parts();
622
        let bytes = http_body_util::BodyExt::collect(body).await.unwrap().to_bytes();
623
        let path = parts.uri.path().to_string();
624
        let header = |h: &str| parts.headers.get(h).and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
625
        if path == "/" && parts.method == "POST" {
626
            let form: Vec<(String, String)> = String::from_utf8_lossy(&bytes)
627
                .split('&')
628
                .filter_map(|kv| kv.split_once('=').map(|(k, v)| (k.to_string(), decode(&v.replace('+', " ")))))
629
                .collect();
630
            let field = |k: &str| form.iter().find(|(f, _)| f == k).map(|(_, v)| v.clone()).unwrap_or_default();
631
            let jwt = field("WebIdentityToken");
632
            let claims: serde_json::Value = serde_json::from_slice(&base64url(jwt.split('.').nth(1).unwrap())).unwrap();
633
            let (sub, aud) = (claims["sub"].as_str().unwrap().to_string(), claims["aud"].as_str().unwrap().to_string());
634
            g.lock().unwrap().exchanged.push((sub.clone(), aud.clone()));
635
            if field("Action") != "AssumeRoleWithWebIdentity" || !sub.starts_with("backup:") || aud != "sts.amazonaws.com" {
636
                return (StatusCode::FORBIDDEN, "<ErrorResponse><Error><Code>AccessDenied</Code><Message>Not authorized to perform sts:AssumeRoleWithWebIdentity</Message></Error></ErrorResponse>").into_response();
637
            }
638
            let owner = sub.trim_start_matches("backup:");
639
            return format!(
640
                "<AssumeRoleWithWebIdentityResponse><AssumeRoleWithWebIdentityResult><Credentials>\
641
                 <AccessKeyId>ASIA{owner}</AccessKeyId><SecretAccessKey>secret/{owner}</SecretAccessKey>\
642
                 <SessionToken>session-{owner}</SessionToken><Expiration>2030-01-01T00:00:00Z</Expiration>\
643
                 </Credentials></AssumeRoleWithWebIdentityResult></AssumeRoleWithWebIdentityResponse>"
644
            )
645
            .into_response();
646
        }
647
        // /s3/{bucket}/{key…}
648
        let rest = path.strip_prefix("/s3/").unwrap_or("");
649
        let (bucket, key) = rest.split_once('/').unwrap_or((rest, ""));
650
        let (bucket, key) = (bucket.to_string(), decode(key));
651
        let owner = bucket.trim_end_matches("-backup").to_string();
652
        // Check the signature as AWS would, with the session's secret.
653
        let session = header("x-amz-security-token");
654
        let who = session.trim_start_matches("session-").to_string();
655
        let query: Vec<(String, String)> = parts
656
            .uri
657
            .query()
658
            .unwrap_or("")
659
            .split('&')
660
            .filter(|kv| !kv.is_empty())
661
            .map(|kv| kv.split_once('=').map(|(k, v)| (decode(k), decode(v))).unwrap_or((decode(kv), String::new())))
662
            .collect();
663
        let signed = vec![
664
            ("host".to_string(), header("host")),
665
            ("x-amz-content-sha256".to_string(), header("x-amz-content-sha256")),
666
            ("x-amz-date".to_string(), header("x-amz-date")),
667
            ("x-amz-security-token".to_string(), session.clone()),
668
        ];
669
        let id = format!("ASIA{who}");
670
        let secret = format!("secret/{who}");
671
        let want = oxygenhub::backups::sigv4(
672
            parts.method.as_str(),
673
            &path,
674
            &query,
675
            &signed,
676
            &header("x-amz-content-sha256"),
677
            (&id, &secret),
678
            "eu-west-2",
679
            "s3",
680
            &header("x-amz-date"),
681
        );
682
        use sha2::Digest;
683
        let body_hash = hex::encode(sha2::Sha256::digest(&bytes));
684
        if header("authorization") != want || header("x-amz-content-sha256") != body_hash {
685
            return (
686
                StatusCode::FORBIDDEN,
687
                "<Error><Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match</Message></Error>",
688
            )
689
                .into_response();
690
        }
691
        if who != owner {
692
            return (StatusCode::FORBIDDEN, "<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>").into_response();
693
        }
694
        let mut g = g.lock().unwrap();
695
        match (parts.method.as_str(), key.is_empty()) {
696
            ("PUT", false) if !parts.headers.contains_key("content-length") => {
697
                (StatusCode::LENGTH_REQUIRED, "<Error><Code>MissingContentLength</Code></Error>").into_response()
698
            }
699
            ("PUT", false) => {
700
                g.objects.insert((bucket, key), bytes.to_vec());
701
                StatusCode::OK.into_response()
702
            }
703
            ("GET", false) | ("HEAD", false) => match g.objects.get(&(bucket, key)) {
704
                Some(b) => b.clone().into_response(),
705
                None => (StatusCode::NOT_FOUND, "<Error><Code>NoSuchKey</Code></Error>").into_response(),
706
            },
707
            ("DELETE", false) => {
708
                g.objects.remove(&(bucket, key));
709
                StatusCode::NO_CONTENT.into_response()
710
            }
711
            ("GET", true) => {
712
                let prefix = query.iter().find(|(k, _)| k == "prefix").map(|(_, v)| v.clone()).unwrap_or_default();
713
                let delimited = query.iter().any(|(k, _)| k == "delimiter");
714
                let (mut keys, mut prefixes) = (String::new(), std::collections::BTreeSet::new());
715
                for (b, n) in g.objects.keys() {
716
                    if *b != bucket || !n.starts_with(&prefix) {
717
                        continue;
718
                    }
719
                    match n[prefix.len()..].find('/') {
720
                        Some(i) if delimited => {
721
                            prefixes.insert(format!("{prefix}{}", &n[prefix.len()..prefix.len() + i + 1]));
722
                        }
723
                        _ => keys.push_str(&format!("<Contents><Key>{n}</Key></Contents>")),
724
                    }
725
                }
726
                let common: String = prefixes.iter().map(|p| format!("<CommonPrefixes><Prefix>{p}</Prefix></CommonPrefixes>")).collect();
727
                format!("<ListBucketResult><Prefix>{prefix}</Prefix><IsTruncated>false</IsTruncated>{keys}{common}</ListBucketResult>")
728
                    .into_response()
729
            }
730
            _ => StatusCode::METHOD_NOT_ALLOWED.into_response(),
731
        }
732
    }
733
    let app = axum::Router::new().fallback(handle).with_state(state.clone());
734
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
735
    let port = listener.local_addr().unwrap().port();
736
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
737
    (format!("http://127.0.0.1:{port}"), state)
738
}
739
740
async fn hub_aws(backups: &Path, aws: &str) -> Hub {
741
    hermetic();
742
    let dir = tempfile::tempdir().unwrap();
743
    let mut config = Config::load(dir.path()).unwrap();
744
    // The first account's setup code (oxygenhub#245).
745
    config.registration.setup_code = "test-setup".into();
746
    config.auth.push_tokens = vec!["site".into()];
747
    config.backups.directories = vec![backups.to_path_buf()];
748
    config.backups.aws_sts_url = aws.to_string();
749
    config.backups.s3_url = format!("{aws}/s3/{{bucket}}");
750
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
751
    let port = listener.local_addr().unwrap().port();
752
    config.server.base_url = format!("http://127.0.0.1:{port}");
753
    let app = oxygenhub::build(config).await.unwrap();
754
    let router = oxygenhub::router(app.clone());
755
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
756
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
757
}
758
759
const ROLE: &str = "arn:aws:iam::123456789012:role/oxygenhub-backup";
760
761
fn s3(owner: &str, bucket: &str) -> Setting {
762
    let mut s = Setting::off(owner, "user");
763
    s.every = "hourly".into();
764
    s.destination = "s3".into();
765
    s.target = bucket.into();
766
    s.provider = ROLE.into();
767
    s.region = "eu-west-2".into();
768
    s.prefix = "oxygen hub".into();
769
    s
770
}
771
772
/// docs/backups.md §3 (BP4): an S3 bucket, keylessly: the hub's own token
773
/// for AWS STS, then signed requests; a second run sends nothing; the
774
/// probe leaves the bucket as it was; a fresh hub restores the same
775
/// repository from it; another owner's session is refused, saying what the
776
/// role needs.
777
#[tokio::test(flavor = "multi_thread")]
778
async fn an_s3_backup_needs_no_key_and_restores_the_same_repository() {
779
    let (aws, seen) = fake_aws().await;
780
    let store = tempfile::tempdir().unwrap();
781
    let h = hub_aws(store.path(), &aws).await;
782
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
783
    db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap();
784
    create(&h.app, "andy", "demo", hub_api::Visibility::Public).await;
785
    let work = tempfile::tempdir().unwrap();
786
    push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n"), ("src/a b+c.rs", "fn main() {}\n")]);
787
    std::fs::write(h.app.repos.dir(&RepoName::parse("andy/demo").unwrap()).join(".oxy/empty-marker"), b"").unwrap();
788
789
    backups::save(&h.app.db, &s3("andy", "andy-backup")).await.unwrap();
790
    let first = backups::run(&h.app, "andy", "now").await.unwrap();
791
    assert_eq!((first.status.as_str(), first.repos), ("ok", 1), "{}", first.detail);
792
    {
793
        let g = seen.lock().unwrap();
794
        assert!(
795
            g.objects.keys().any(|(b, n)| b == "andy-backup" && n == &format!("oxygen hub/runs/{}/DONE", first.at)),
796
            "under the prefix"
797
        );
798
        assert_eq!(g.exchanged.first().map(|(s, a)| (s.as_str(), a.as_str())), Some(("backup:andy", "sts.amazonaws.com")));
799
    }
800
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
801
    let second = backups::run(&h.app, "andy", "now").await.unwrap();
802
    assert_eq!((second.status.as_str(), second.sent), ("ok", 0), "{}", second.detail);
803
    let before = seen.lock().unwrap().objects.len();
804
    let dest = backups::destination(&h.app, &s3("andy", "andy-backup")).unwrap();
805
    tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap().unwrap();
806
    assert_eq!(seen.lock().unwrap().objects.len(), before, "the probe cleans up");
807
808
    let empty = tempfile::tempdir().unwrap();
809
    let fresh = hub_aws(empty.path(), &aws).await;
810
    let dest: Arc<dyn Destination> = backups::destination(&fresh.app, &s3("andy", "andy-backup")).unwrap().into();
811
    assert_eq!(backups::restore_into_hub(&fresh.app, dest, None).await.unwrap(), vec!["andy/demo (repos)"]);
812
    let name = RepoName::parse("andy/demo").unwrap();
813
    assert_eq!(files(&fresh.app.repos.dir(&name)), files(&h.app.repos.dir(&name)), "byte for byte");
814
815
    backups::save(&h.app.db, &s3("bob", "andy-backup")).await.unwrap();
816
    let refused = backups::run(&h.app, "bob", "now").await.unwrap();
817
    assert_eq!(refused.status, "failed");
818
    assert!(refused.detail.contains("s3:PutObject") && refused.detail.contains(ROLE), "{}", refused.detail);
819
}
820
821
/// One API call with a bearer token: the status and the JSON answer.
822
async fn api(
823
    app: &Shared,
824
    method: &str,
825
    path: &str,
826
    token: &str,
827
    body: Option<serde_json::Value>,
828
) -> (axum::http::StatusCode, serde_json::Value) {
829
    use axum::body::Body;
830
    use http_body_util::BodyExt;
831
    use tower::ServiceExt;
832
    let mut req =
833
        axum::http::Request::builder().method(method).uri(format!("/api/v1{path}")).header("authorization", format!("Bearer {token}"));
834
    let body = match body {
835
        Some(b) => {
836
            req = req.header("content-type", "application/json");
837
            Body::from(b.to_string())
838
        }
839
        None => Body::empty(),
840
    };
841
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
842
    let st = res.status();
843
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
844
    (st, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
845
}
846
847
/// oxygenhub#179: the API does what the Backups pages do, for the caller's
848
/// own repositories and an organisation it owns, with repo:admin: the
849
/// setting (checked as the page checks it), TEST, back up now, the runs.
850
#[tokio::test(flavor = "multi_thread")]
851
async fn backups_are_set_up_and_run_through_the_api() {
852
    let store = tempfile::tempdir().unwrap();
853
    let h = hub(store.path()).await;
854
    let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
855
    let bob = db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap();
856
    db::accounts::create_org(&h.app.db, "club", "Club", andy.id).await.unwrap();
857
    create(&h.app, "andy", "demo", hub_api::Visibility::Public).await;
858
    create(&h.app, "club", "game", hub_api::Visibility::Private).await;
859
    let token = |id: i64, scopes: &'static str| {
860
        let db = h.app.db.clone();
861
        async move {
862
            let (t, hash) = oxygenhub::auth::new_token();
863
            db::accounts::create_token(&db, id, "t", &hash, scopes, "", None).await.unwrap();
864
            t
865
        }
866
    };
867
    let (andy_t, bob_t, weak) = (token(andy.id, "repo:admin").await, token(bob.id, "repo:admin").await, token(andy.id, "repo:read").await);
868
    let dir = store.path().display().to_string();
869
870
    let (st, empty) = api(&h.app, "GET", "/user/backups", &andy_t, None).await;
871
    assert_eq!(st, 200);
872
    assert!(empty["setting"].is_null() && empty["runs"].as_array().unwrap().is_empty(), "{empty}");
873
    let (st, _) = api(&h.app, "POST", "/user/backups/run", &andy_t, None).await;
874
    assert_eq!(st, 409, "nothing saved yet");
875
    let (st, _) =
876
        api(&h.app, "PUT", "/user/backups", &weak, Some(serde_json::json!({ "every": "hourly", "destination": "dir", "dir": dir }))).await;
877
    assert_eq!(st, 403, "repo:admin only");
878
    let (st, bad) = api(
879
        &h.app,
880
        "PUT",
881
        "/user/backups",
882
        &andy_t,
883
        Some(serde_json::json!({ "every": "hourly", "destination": "gcs", "bucket": "andy-backup", "provider": "nope" })),
884
    )
885
    .await;
886
    assert_eq!(st, 400, "checked as the page checks it: {bad}");
887
    let (st, saved) = api(
888
        &h.app,
889
        "PUT",
890
        "/user/backups",
891
        &andy_t,
892
        Some(serde_json::json!({ "every": "hourly", "destination": "dir", "dir": dir, "keep_days": 30 })),
893
    )
894
    .await;
895
    assert_eq!(st, 200, "{saved}");
896
    assert_eq!((saved["setting"]["every"].as_str(), saved["place"].as_str()), (Some("hourly"), Some(format!("{dir}/andy").as_str())));
897
    let (st, probe) = api(&h.app, "POST", "/user/backups/test", &andy_t, None).await;
898
    assert_eq!((st.as_u16(), probe["ok"].as_bool()), (200, Some(true)), "{probe}");
899
    let (st, started) = api(&h.app, "POST", "/user/backups/run", &andy_t, None).await;
900
    assert_eq!(st, 202, "{started}");
901
    let mut done = serde_json::Value::Null;
902
    for _ in 0..200 {
903
        let (_, runs) = api(&h.app, "GET", "/user/backups/runs", &andy_t, None).await;
904
        if runs[0]["status"].as_str().is_some_and(|s| s != "running") {
905
            done = runs[0].clone();
906
            break;
907
        }
908
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
909
    }
910
    assert_eq!((done["status"].as_str(), done["repos"].as_i64(), done["trigger"].as_str()), (Some("ok"), Some(1), Some("now")), "{done}");
911
    // A whole run after a failed one says it works again; the day's
912
    // summary tells the owner what ran (#183).
913
    sqlx::query("INSERT INTO backup_runs (owner, started_ms, finished_ms, status, at, trigger, detail) VALUES ('andy', 1, 2, 'failed', 'x', 'schedule', 'GCS refused')")
914
        .execute(&h.app.db)
915
        .await
916
        .unwrap();
917
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
918
    let again = backups::run(&h.app, "andy", "now").await.unwrap();
919
    assert_eq!(again.status, "ok");
920
    let told = |db: oxygenhub::db::Pool| async move { db::notify::inbox(&db, andy.id, 20).await.unwrap() };
921
    assert!(told(h.app.db.clone()).await.iter().any(|n| n.kind == "backup.recovered" && n.title.contains("works again")));
922
    assert_eq!(backups::daily_summary(&h.app, db::now_ms(), true).await.unwrap(), 1);
923
    let summary = told(h.app.db.clone()).await.into_iter().find(|n| n.kind == "backup.summary").expect("a summary");
924
    assert!(summary.title.contains("Backups of andy today") && summary.title.contains("runs"), "{}", summary.title);
925
926
    // An organisation's, by its owner; another account sees no such thing.
927
    let (st, _) =
928
        api(&h.app, "PUT", "/orgs/club/backups", &bob_t, Some(serde_json::json!({ "every": "hourly", "destination": "dir", "dir": dir })))
929
            .await;
930
    assert_eq!(st, 404);
931
    let (st, org) = api(
932
        &h.app,
933
        "PUT",
934
        "/orgs/club/backups",
935
        &andy_t,
936
        Some(serde_json::json!({ "every": "daily", "destination": "dir", "dir": dir, "keep_days": 90 })),
937
    )
938
    .await;
939
    assert_eq!(st, 200, "{org}");
940
    assert_eq!((org["setting"]["kind"].as_str(), org["setting"]["keep_days"].as_i64()), (Some("org"), Some(90)));
941
    let (st, _) = api(&h.app, "GET", "/orgs/club/backups", &bob_t, None).await;
942
    assert_eq!(st, 404);
943
}