| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
#[tokio::test(flavor = "multi_thread")] |
| 159 |
async fn backups_fall_due_on_their_schedule() { |
| 160 |
let store = tempfile::tempdir().unwrap(); |
| 161 |
let h = hub(store.path()).await; |
| 162 |
let mut s = Setting::off("andy", "user"); |
| 163 |
s.target = store.path().display().to_string(); |
| 164 |
backups::save(&h.app.db, &s).await.unwrap(); |
| 165 |
assert!(backups::due(&h.app.db).await.unwrap().is_empty(), "off is never due"); |
| 166 |
s.every = "hourly".into(); |
| 167 |
backups::save(&h.app.db, &s).await.unwrap(); |
| 168 |
assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1, "never run: due"); |
| 169 |
sqlx::query("INSERT INTO backup_runs (owner, started_ms, status) VALUES ('andy', $1, 'ok')") |
| 170 |
.bind(db::now_ms()) |
| 171 |
.execute(&h.app.db) |
| 172 |
.await |
| 173 |
.unwrap(); |
| 174 |
assert!(backups::due(&h.app.db).await.unwrap().is_empty(), "ran just now"); |
| 175 |
} |
| 176 |
|
| 177 |
|
| 178 |
|
| 179 |
|
| 180 |
#[derive(Default)] |
| 181 |
struct Google { |
| 182 |
|
| 183 |
objects: std::collections::BTreeMap<(String, String), Vec<u8>>, |
| 184 |
|
| 185 |
exchanged: Vec<(String, String)>, |
| 186 |
} |
| 187 |
|
| 188 |
type Shared2 = Arc<std::sync::Mutex<Google>>; |
| 189 |
|
| 190 |
fn decode(s: &str) -> String { |
| 191 |
let b = s.as_bytes(); |
| 192 |
let mut out = Vec::new(); |
| 193 |
let mut i = 0; |
| 194 |
while i < b.len() { |
| 195 |
if b[i] == b'%' && i + 2 < b.len() { |
| 196 |
out.push(u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap()); |
| 197 |
i += 3; |
| 198 |
} else { |
| 199 |
out.push(b[i]); |
| 200 |
i += 1; |
| 201 |
} |
| 202 |
} |
| 203 |
String::from_utf8(out).unwrap() |
| 204 |
} |
| 205 |
|
| 206 |
fn query(uri: &axum::http::Uri, key: &str) -> Option<String> { |
| 207 |
uri.query()?.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| decode(v))) |
| 208 |
} |
| 209 |
|
| 210 |
|
| 211 |
|
| 212 |
|
| 213 |
async fn fake_google() -> (String, Shared2) { |
| 214 |
use axum::extract::{Request, State}; |
| 215 |
use axum::http::StatusCode; |
| 216 |
use axum::response::IntoResponse; |
| 217 |
let state: Shared2 = Default::default(); |
| 218 |
async fn handle(State(g): State<Shared2>, req: Request) -> axum::response::Response { |
| 219 |
let (parts, body) = req.into_parts(); |
| 220 |
let bytes = http_body_util::BodyExt::collect(body).await.unwrap().to_bytes(); |
| 221 |
let path = parts.uri.path().to_string(); |
| 222 |
if path == "/v1/token" { |
| 223 |
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); |
| 224 |
let jwt = v["subjectToken"].as_str().unwrap(); |
| 225 |
let payload = jwt.split('.').nth(1).unwrap(); |
| 226 |
let claims: serde_json::Value = serde_json::from_slice(&base64url(payload)).unwrap(); |
| 227 |
let (sub, aud) = (claims["sub"].as_str().unwrap().to_string(), claims["aud"].as_str().unwrap().to_string()); |
| 228 |
let provider = v["audience"].as_str().unwrap(); |
| 229 |
g.lock().unwrap().exchanged.push((sub.clone(), aud.clone())); |
| 230 |
if !sub.starts_with("backup:") || aud != format!("https:{provider}") { |
| 231 |
return ( |
| 232 |
StatusCode::BAD_REQUEST, |
| 233 |
axum::Json(serde_json::json!({"error": "invalid_grant", "error_description": "not admitted"})), |
| 234 |
) |
| 235 |
.into_response(); |
| 236 |
} |
| 237 |
return axum::Json(serde_json::json!({ "access_token": format!("tok-{sub}"), "expires_in": 3600 })).into_response(); |
| 238 |
} |
| 239 |
let auth = parts.headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); |
| 240 |
let segs: Vec<&str> = path.split('/').collect(); |
| 241 |
|
| 242 |
let upload = segs.get(1) == Some(&"upload"); |
| 243 |
let at = if upload { 5 } else { 4 }; |
| 244 |
let bucket = segs.get(at).unwrap_or(&"").to_string(); |
| 245 |
let owner = bucket.trim_end_matches("-backup"); |
| 246 |
if auth != format!("Bearer tok-backup:{owner}") { |
| 247 |
return (StatusCode::FORBIDDEN, "caller does not have storage.objects access").into_response(); |
| 248 |
} |
| 249 |
let mut g = g.lock().unwrap(); |
| 250 |
if upload { |
| 251 |
let name = query(&parts.uri, "name").unwrap(); |
| 252 |
g.objects.insert((bucket, name), bytes.to_vec()); |
| 253 |
return axum::Json(serde_json::json!({})).into_response(); |
| 254 |
} |
| 255 |
let name = segs.get(at + 2).map(|n| decode(n)); |
| 256 |
match (parts.method.as_str(), name) { |
| 257 |
("GET", Some(n)) => match g.objects.get(&(bucket, n.clone())) { |
| 258 |
Some(b) if query(&parts.uri, "alt").as_deref() == Some("media") => b.clone().into_response(), |
| 259 |
Some(_) => axum::Json(serde_json::json!({ "name": n })).into_response(), |
| 260 |
None => StatusCode::NOT_FOUND.into_response(), |
| 261 |
}, |
| 262 |
("DELETE", Some(n)) => { |
| 263 |
g.objects.remove(&(bucket, n)); |
| 264 |
StatusCode::NO_CONTENT.into_response() |
| 265 |
} |
| 266 |
("GET", None) => { |
| 267 |
let prefix = query(&parts.uri, "prefix").unwrap_or_default(); |
| 268 |
let delimited = query(&parts.uri, "delimiter").is_some(); |
| 269 |
let (mut items, mut prefixes) = (Vec::new(), std::collections::BTreeSet::new()); |
| 270 |
for (b, n) in g.objects.keys() { |
| 271 |
if *b != bucket || !n.starts_with(&prefix) { |
| 272 |
continue; |
| 273 |
} |
| 274 |
let rest = &n[prefix.len()..]; |
| 275 |
match rest.find('/') { |
| 276 |
Some(i) if delimited => { |
| 277 |
prefixes.insert(format!("{prefix}{}", &rest[..=i])); |
| 278 |
} |
| 279 |
_ => items.push(serde_json::json!({ "name": n })), |
| 280 |
} |
| 281 |
} |
| 282 |
axum::Json(serde_json::json!({ "items": items, "prefixes": prefixes })).into_response() |
| 283 |
} |
| 284 |
_ => StatusCode::METHOD_NOT_ALLOWED.into_response(), |
| 285 |
} |
| 286 |
} |
| 287 |
let app = axum::Router::new().fallback(handle).with_state(state.clone()); |
| 288 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 289 |
let port = listener.local_addr().unwrap().port(); |
| 290 |
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); |
| 291 |
(format!("http://127.0.0.1:{port}"), state) |
| 292 |
} |
| 293 |
|
| 294 |
fn base64url(s: &str) -> Vec<u8> { |
| 295 |
let table = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; |
| 296 |
let mut bits = 0u32; |
| 297 |
let mut n = 0; |
| 298 |
let mut out = Vec::new(); |
| 299 |
for c in s.bytes() { |
| 300 |
let v = table.iter().position(|t| *t == c).unwrap() as u32; |
| 301 |
bits = (bits << 6) | v; |
| 302 |
n += 6; |
| 303 |
if n >= 8 { |
| 304 |
n -= 8; |
| 305 |
out.push((bits >> n) as u8); |
| 306 |
bits &= (1 << n) - 1; |
| 307 |
} |
| 308 |
} |
| 309 |
out |
| 310 |
} |
| 311 |
|
| 312 |
const PROVIDER: &str = "//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io"; |
| 313 |
|
| 314 |
fn gcs(owner: &str, bucket: &str) -> Setting { |
| 315 |
let mut s = Setting::off(owner, "user"); |
| 316 |
s.every = "hourly".into(); |
| 317 |
s.destination = "gcs".into(); |
| 318 |
s.target = bucket.into(); |
| 319 |
s.provider = PROVIDER.into(); |
| 320 |
s |
| 321 |
} |
| 322 |
|
| 323 |
#[tokio::test(flavor = "multi_thread")] |
| 324 |
async fn a_gcs_backup_needs_no_key_and_restores_the_same_repository() { |
| 325 |
let (google, seen) = fake_google().await; |
| 326 |
let store = tempfile::tempdir().unwrap(); |
| 327 |
let h = hub_with(store.path(), Some(&google)).await; |
| 328 |
db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
| 329 |
db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap(); |
| 330 |
create(&h.app, "andy", "demo", hub_api::Visibility::Public).await; |
| 331 |
let work = tempfile::tempdir().unwrap(); |
| 332 |
push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n")]); |
| 333 |
|
| 334 |
backups::save(&h.app.db, &gcs("andy", "andy-backup")).await.unwrap(); |
| 335 |
let first = backups::run(&h.app, "andy", "now").await.unwrap(); |
| 336 |
assert_eq!((first.status.as_str(), first.repos), ("ok", 1), "{}", first.detail); |
| 337 |
{ |
| 338 |
let g = seen.lock().unwrap(); |
| 339 |
assert!(g.objects.keys().any(|(b, n)| b == "andy-backup" && n == &format!("runs/{}/DONE", first.at))); |
| 340 |
assert!(g.objects.keys().all(|(b, _)| b == "andy-backup")); |
| 341 |
let (sub, aud) = g.exchanged.first().unwrap(); |
| 342 |
assert_eq!( |
| 343 |
(sub.as_str(), aud.as_str()), |
| 344 |
("backup:andy", format!("https:{PROVIDER}").as_str()), |
| 345 |
"the hub signs in as the owner, for the provider" |
| 346 |
); |
| 347 |
} |
| 348 |
tokio::time::sleep(std::time::Duration::from_millis(1100)).await; |
| 349 |
let second = backups::run(&h.app, "andy", "now").await.unwrap(); |
| 350 |
assert_eq!((second.status.as_str(), second.sent), ("ok", 0), "{}", second.detail); |
| 351 |
|
| 352 |
let before = seen.lock().unwrap().objects.len(); |
| 353 |
let dest = backups::destination(&h.app, &gcs("andy", "andy-backup")).unwrap(); |
| 354 |
tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap().unwrap(); |
| 355 |
assert_eq!(seen.lock().unwrap().objects.len(), before); |
| 356 |
|
| 357 |
|
| 358 |
let empty = tempfile::tempdir().unwrap(); |
| 359 |
let fresh = hub_with(empty.path(), Some(&google)).await; |
| 360 |
let dest: Arc<dyn Destination> = backups::destination(&fresh.app, &gcs("andy", "andy-backup")).unwrap().into(); |
| 361 |
assert_eq!(backups::restore_into_hub(&fresh.app, dest, None).await.unwrap(), vec!["andy/demo (repos)"]); |
| 362 |
let name = RepoName::parse("andy/demo").unwrap(); |
| 363 |
assert_eq!(files(&fresh.app.repos.dir(&name)), files(&h.app.repos.dir(&name)), "byte for byte"); |
| 364 |
|
| 365 |
|
| 366 |
|
| 367 |
backups::save(&h.app.db, &gcs("bob", "andy-backup")).await.unwrap(); |
| 368 |
let refused = backups::run(&h.app, "bob", "now").await.unwrap(); |
| 369 |
assert_eq!(refused.status, "failed"); |
| 370 |
assert!(refused.detail.contains("roles/storage.objectAdmin") && refused.detail.contains("backup:bob"), "{}", refused.detail); |
| 371 |
} |
| 372 |
|
| 373 |
|
| 374 |
|
| 375 |
|
| 376 |
|
| 377 |
|
| 378 |
#[tokio::test(flavor = "multi_thread")] |
| 379 |
async fn a_restore_brings_back_issues_proposals_and_releases() { |
| 380 |
let store = tempfile::tempdir().unwrap(); |
| 381 |
let h = hub(store.path()).await; |
| 382 |
let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
| 383 |
let bob = db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap(); |
| 384 |
create(&h.app, "andy", "demo", hub_api::Visibility::Public).await; |
| 385 |
let work = tempfile::tempdir().unwrap(); |
| 386 |
push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n")]); |
| 387 |
let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap(); |
| 388 |
db::repos::set_diffusion(&h.app.db, row.id, "green").await.unwrap(); |
| 389 |
|
| 390 |
for n in 0..3 { |
| 391 |
db::issues::upsert_label(&h.app.db, row.id, &format!("spare{n}"), "#000000", "").await.unwrap(); |
| 392 |
} |
| 393 |
let bug = db::issues::upsert_label(&h.app.db, row.id, "bug", "#d73a4a", "Something is wrong").await.unwrap(); |
| 394 |
let m = db::issues::upsert_milestone(&h.app.db, row.id, "1.0", "the first", None).await.unwrap(); |
| 395 |
let issue = db::issues::create( |
| 396 |
&h.app.db, |
| 397 |
db::issues::NewIssue { repo_id: row.id, title: "Sprites flicker", body: "on level two", author_id: Some(andy.id), author: "andy" }, |
| 398 |
) |
| 399 |
.await |
| 400 |
.unwrap(); |
| 401 |
db::issues::add_comment(&h.app.db, issue.id, Some(bob.id), "bob", "seen it too").await.unwrap(); |
| 402 |
db::issues::set_issue_label(&h.app.db, issue.id, bug.id, true).await.unwrap(); |
| 403 |
db::issues::set_milestone(&h.app.db, issue.id, Some(m.id)).await.unwrap(); |
| 404 |
db::issues::set_assignee(&h.app.db, issue.id, andy.id, true).await.unwrap(); |
| 405 |
db::issues::set_assignee(&h.app.db, issue.id, bob.id, true).await.unwrap(); |
| 406 |
let p = db::proposals::create( |
| 407 |
&h.app.db, |
| 408 |
db::proposals::NewProposal { |
| 409 |
repo_id: row.id, |
| 410 |
title: "Double-buffer the sprites", |
| 411 |
body: "fixes the flicker", |
| 412 |
author_id: Some(bob.id), |
| 413 |
author: "bob", |
| 414 |
draft: false, |
| 415 |
target: "main", |
| 416 |
source_kind: "bookmark", |
| 417 |
source_repo_id: Some(row.id), |
| 418 |
source_bookmark: "fix", |
| 419 |
source_changes: "", |
| 420 |
}, |
| 421 |
) |
| 422 |
.await |
| 423 |
.unwrap(); |
| 424 |
db::proposals::add_event(&h.app.db, p.id, "bob", "opened", "").await.unwrap(); |
| 425 |
db::proposals::add_review(&h.app.db, p.id, andy.id, "andy", "approve", "nice").await.unwrap(); |
| 426 |
let release = db::releases::create(&h.app.db, row.id, "v1.0", "One", "notes", false, Some((andy.id, "andy"))).await.unwrap(); |
| 427 |
let asset = b"a game image".to_vec(); |
| 428 |
let hash = blake3::hash(&asset).to_hex().to_string(); |
| 429 |
let path = h.app.config.data.join("assets").join(&hash[..2]).join(&hash); |
| 430 |
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); |
| 431 |
std::fs::write(&path, &asset).unwrap(); |
| 432 |
db::releases::add_asset(&h.app.db, release.id, "game.prg", asset.len() as i64, &hash, "application/octet-stream").await.unwrap(); |
| 433 |
db::hooks::create_hook(&h.app.db, Some(row.id), None, "https://example.com/hook", "s3cret-value", "push").await.unwrap(); |
| 434 |
|
| 435 |
let mut s = Setting::off("andy", "user"); |
| 436 |
s.every = "hourly".into(); |
| 437 |
s.target = store.path().display().to_string(); |
| 438 |
backups::save(&h.app.db, &s).await.unwrap(); |
| 439 |
let run = backups::run(&h.app, "andy", "now").await.unwrap(); |
| 440 |
assert_eq!(run.status, "ok", "{}", run.detail); |
| 441 |
let dest = Directory { root: store.path().join("andy") }; |
| 442 |
let text = String::from_utf8_lossy(&dest.get(&format!("runs/{}/records/demo.json", run.at)).unwrap()).into_owned(); |
| 443 |
assert!(!text.contains("s3cret-value"), "never a secret"); |
| 444 |
assert!(text.contains("\"_names\"") && text.contains("\"bob\""), "accounts by name"); |
| 445 |
|
| 446 |
|
| 447 |
let empty = tempfile::tempdir().unwrap(); |
| 448 |
let fresh = hub(empty.path()).await; |
| 449 |
let andy2 = db::accounts::create_account(&fresh.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
| 450 |
let dest: Arc<dyn Destination> = Arc::new(Directory { root: store.path().join("andy") }); |
| 451 |
let done = backups::restore_into_hub(&fresh.app, dest, None).await.unwrap(); |
| 452 |
assert_eq!(done[0], "andy/demo (repos)"); |
| 453 |
let notes = done[1..].join("\n"); |
| 454 |
assert!(notes.contains("webhook https://example.com/hook: enter its secret again"), "{notes}"); |
| 455 |
assert!(notes.contains("issue_assignees: 1 row(s) left out"), "bob is not on this hub: {notes}"); |
| 456 |
let r2 = db::repos::get(&fresh.app.db, "andy", "demo").await.unwrap().unwrap(); |
| 457 |
assert_eq!(r2.diffusion, "green", "settings come back"); |
| 458 |
|
| 459 |
let issues: Vec<(i64, i64, String, Option<i64>)> = sqlx::query_as("SELECT id, number, title, author_id FROM issues WHERE repo_id = $1") |
| 460 |
.bind(r2.id) |
| 461 |
.fetch_all(&fresh.app.db) |
| 462 |
.await |
| 463 |
.unwrap(); |
| 464 |
assert_eq!(issues.len(), 1); |
| 465 |
let (iid, number, title, author) = issues[0].clone(); |
| 466 |
assert_eq!((number, title.as_str(), author), (issue.number, "Sprites flicker", Some(andy2.id))); |
| 467 |
let comments = db::issues::comments(&fresh.app.db, iid).await.unwrap(); |
| 468 |
assert_eq!(comments.len(), 1); |
| 469 |
assert_eq!((comments[0].author.as_str(), comments[0].body.as_str()), ("bob", "seen it too")); |
| 470 |
let labels: Vec<String> = |
| 471 |
sqlx::query_scalar("SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = $1") |
| 472 |
.bind(iid) |
| 473 |
.fetch_all(&fresh.app.db) |
| 474 |
.await |
| 475 |
.unwrap(); |
| 476 |
assert_eq!(labels, vec!["bug"], "the label, its id mapped"); |
| 477 |
let milestone: Option<String> = |
| 478 |
sqlx::query_scalar("SELECT m.title FROM issues i JOIN milestones m ON m.id = i.milestone_id WHERE i.id = $1") |
| 479 |
.bind(iid) |
| 480 |
.fetch_one(&fresh.app.db) |
| 481 |
.await |
| 482 |
.unwrap(); |
| 483 |
assert_eq!(milestone.as_deref(), Some("1.0")); |
| 484 |
assert_eq!(db::issues::assignees(&fresh.app.db, iid).await.unwrap(), vec!["andy"]); |
| 485 |
|
| 486 |
let p2 = db::proposals::get(&fresh.app.db, r2.id, p.number).await.unwrap().expect("the proposal"); |
| 487 |
assert_eq!((p2.title.as_str(), p2.author.as_str(), p2.author_id), ("Double-buffer the sprites", "bob", None)); |
| 488 |
let reviews: Vec<(String, String)> = sqlx::query_as("SELECT author, verdict FROM proposal_reviews WHERE proposal_id = $1") |
| 489 |
.bind(p2.id) |
| 490 |
.fetch_all(&fresh.app.db) |
| 491 |
.await |
| 492 |
.unwrap(); |
| 493 |
assert_eq!(reviews, vec![("andy".to_string(), "approve".to_string())]); |
| 494 |
assert!(db::proposals::events(&fresh.app.db, p2.id).await.unwrap().iter().any(|e| e.event == "opened")); |
| 495 |
|
| 496 |
let rel = db::releases::get(&fresh.app.db, r2.id, "v1.0").await.unwrap().expect("the release"); |
| 497 |
let assets = db::releases::assets(&fresh.app.db, rel.id).await.unwrap(); |
| 498 |
assert_eq!(assets.len(), 1); |
| 499 |
assert_eq!(std::fs::read(fresh.app.config.data.join("assets").join(&hash[..2]).join(&hash)).unwrap(), asset); |
| 500 |
|
| 501 |
let hits = db::notify::search(&fresh.app.db, "flicker", Some(r2.id), 10).await.unwrap(); |
| 502 |
assert!(hits.iter().any(|h| h.kind == "issue" && h.target_id == iid), "{:?}", hits.iter().map(|h| &h.title).collect::<Vec<_>>()); |
| 503 |
|
| 504 |
let next = db::issues::create( |
| 505 |
&fresh.app.db, |
| 506 |
db::issues::NewIssue { repo_id: r2.id, title: "After", body: "", author_id: Some(andy2.id), author: "andy" }, |
| 507 |
) |
| 508 |
.await |
| 509 |
.unwrap(); |
| 510 |
assert!(next.number > p.number, "{} after {}", next.number, p.number); |
| 511 |
} |
| 512 |
|
| 513 |
|
| 514 |
|
| 515 |
|
| 516 |
|
| 517 |
#[tokio::test(flavor = "multi_thread")] |
| 518 |
async fn repositories_choose_their_own_backup() { |
| 519 |
let store = tempfile::tempdir().unwrap(); |
| 520 |
let h = hub(store.path()).await; |
| 521 |
db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
| 522 |
for n in ["game", "notes", "secret"] { |
| 523 |
create(&h.app, "andy", n, hub_api::Visibility::Public).await; |
| 524 |
let work = tempfile::tempdir().unwrap(); |
| 525 |
push(&format!("{}/andy/{n}", h.base), work.path(), &[("README.md", n)]); |
| 526 |
} |
| 527 |
let id = |n: &'static str| { |
| 528 |
let db = h.app.db.clone(); |
| 529 |
async move { db::repos::get(&db, "andy", n).await.unwrap().unwrap().id } |
| 530 |
}; |
| 531 |
db::repos::set_backup(&h.app.db, id("secret").await, "off").await.unwrap(); |
| 532 |
let mut s = Setting::off("andy", "user"); |
| 533 |
s.every = "daily".into(); |
| 534 |
s.target = store.path().display().to_string(); |
| 535 |
backups::save(&h.app.db, &s).await.unwrap(); |
| 536 |
let names = |at: String| { |
| 537 |
let dest = Directory { root: store.path().join("andy") }; |
| 538 |
let mut v = dest.list(&format!("runs/{at}/records")).unwrap(); |
| 539 |
v.sort(); |
| 540 |
v |
| 541 |
}; |
| 542 |
let first = backups::run(&h.app, "andy", "schedule").await.unwrap(); |
| 543 |
assert_eq!((first.status.as_str(), first.repos), ("ok", 2), "{}", first.detail); |
| 544 |
assert_eq!(names(first.at.clone()), vec!["game.json", "notes.json"], "the one left out is not in it"); |
| 545 |
|
| 546 |
assert!(backups::due(&h.app.db).await.unwrap().is_empty()); |
| 547 |
db::repos::set_backup(&h.app.db, id("game").await, "hourly").await.unwrap(); |
| 548 |
sqlx::query("UPDATE backup_runs SET started_ms = started_ms - 2 * 3600 * 1000").execute(&h.app.db).await.unwrap(); |
| 549 |
assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1, "hourly for the game"); |
| 550 |
|
| 551 |
s.every = "off".into(); |
| 552 |
backups::save(&h.app.db, &s).await.unwrap(); |
| 553 |
assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1); |
| 554 |
tokio::time::sleep(std::time::Duration::from_millis(1100)).await; |
| 555 |
let second = backups::run(&h.app, "andy", "schedule").await.unwrap(); |
| 556 |
assert_eq!(names(second.at.clone()), vec!["game.json"]); |
| 557 |
|
| 558 |
tokio::time::sleep(std::time::Duration::from_millis(1100)).await; |
| 559 |
let third = backups::run(&h.app, "andy", "now").await.unwrap(); |
| 560 |
assert_eq!(names(third.at.clone()), vec!["game.json", "notes.json"]); |
| 561 |
|
| 562 |
let (owners, uncovered) = backups::overview(&h.app.db).await.unwrap(); |
| 563 |
assert_eq!((owners[0].repos, uncovered.len()), (1, 0)); |
| 564 |
} |
| 565 |
|
| 566 |
|
| 567 |
|
| 568 |
|
| 569 |
|
| 570 |
async fn fake_aws() -> (String, Shared2) { |
| 571 |
use axum::extract::{Request, State}; |
| 572 |
use axum::http::StatusCode; |
| 573 |
use axum::response::IntoResponse; |
| 574 |
let state: Shared2 = Default::default(); |
| 575 |
async fn handle(State(g): State<Shared2>, req: Request) -> axum::response::Response { |
| 576 |
let (parts, body) = req.into_parts(); |
| 577 |
let bytes = http_body_util::BodyExt::collect(body).await.unwrap().to_bytes(); |
| 578 |
let path = parts.uri.path().to_string(); |
| 579 |
let header = |h: &str| parts.headers.get(h).and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); |
| 580 |
if path == "/" && parts.method == "POST" { |
| 581 |
let form: Vec<(String, String)> = String::from_utf8_lossy(&bytes) |
| 582 |
.split('&') |
| 583 |
.filter_map(|kv| kv.split_once('=').map(|(k, v)| (k.to_string(), decode(&v.replace('+', " "))))) |
| 584 |
.collect(); |
| 585 |
let field = |k: &str| form.iter().find(|(f, _)| f == k).map(|(_, v)| v.clone()).unwrap_or_default(); |
| 586 |
let jwt = field("WebIdentityToken"); |
| 587 |
let claims: serde_json::Value = serde_json::from_slice(&base64url(jwt.split('.').nth(1).unwrap())).unwrap(); |
| 588 |
let (sub, aud) = (claims["sub"].as_str().unwrap().to_string(), claims["aud"].as_str().unwrap().to_string()); |
| 589 |
g.lock().unwrap().exchanged.push((sub.clone(), aud.clone())); |
| 590 |
if field("Action") != "AssumeRoleWithWebIdentity" || !sub.starts_with("backup:") || aud != "sts.amazonaws.com" { |
| 591 |
return (StatusCode::FORBIDDEN, "<ErrorResponse><Error><Code>AccessDenied</Code><Message>Not authorized to perform sts:AssumeRoleWithWebIdentity</Message></Error></ErrorResponse>").into_response(); |
| 592 |
} |
| 593 |
let owner = sub.trim_start_matches("backup:"); |
| 594 |
return format!( |
| 595 |
"<AssumeRoleWithWebIdentityResponse><AssumeRoleWithWebIdentityResult><Credentials>\ |
| 596 |
<AccessKeyId>ASIA{owner}</AccessKeyId><SecretAccessKey>secret/{owner}</SecretAccessKey>\ |
| 597 |
<SessionToken>session-{owner}</SessionToken><Expiration>2030-01-01T00:00:00Z</Expiration>\ |
| 598 |
</Credentials></AssumeRoleWithWebIdentityResult></AssumeRoleWithWebIdentityResponse>" |
| 599 |
) |
| 600 |
.into_response(); |
| 601 |
} |
| 602 |
|
| 603 |
let rest = path.strip_prefix("/s3/").unwrap_or(""); |
| 604 |
let (bucket, key) = rest.split_once('/').unwrap_or((rest, "")); |
| 605 |
let (bucket, key) = (bucket.to_string(), decode(key)); |
| 606 |
let owner = bucket.trim_end_matches("-backup").to_string(); |
| 607 |
|
| 608 |
let session = header("x-amz-security-token"); |
| 609 |
let who = session.trim_start_matches("session-").to_string(); |
| 610 |
let query: Vec<(String, String)> = parts |
| 611 |
.uri |
| 612 |
.query() |
| 613 |
.unwrap_or("") |
| 614 |
.split('&') |
| 615 |
.filter(|kv| !kv.is_empty()) |
| 616 |
.map(|kv| kv.split_once('=').map(|(k, v)| (decode(k), decode(v))).unwrap_or((decode(kv), String::new()))) |
| 617 |
.collect(); |
| 618 |
let signed = vec![ |
| 619 |
("host".to_string(), header("host")), |
| 620 |
("x-amz-content-sha256".to_string(), header("x-amz-content-sha256")), |
| 621 |
("x-amz-date".to_string(), header("x-amz-date")), |
| 622 |
("x-amz-security-token".to_string(), session.clone()), |
| 623 |
]; |
| 624 |
let id = format!("ASIA{who}"); |
| 625 |
let secret = format!("secret/{who}"); |
| 626 |
let want = oxygenhub::backups::sigv4( |
| 627 |
parts.method.as_str(), |
| 628 |
&path, |
| 629 |
&query, |
| 630 |
&signed, |
| 631 |
&header("x-amz-content-sha256"), |
| 632 |
(&id, &secret), |
| 633 |
"eu-west-2", |
| 634 |
"s3", |
| 635 |
&header("x-amz-date"), |
| 636 |
); |
| 637 |
use sha2::Digest; |
| 638 |
let body_hash = hex::encode(sha2::Sha256::digest(&bytes)); |
| 639 |
if header("authorization") != want || header("x-amz-content-sha256") != body_hash { |
| 640 |
return ( |
| 641 |
StatusCode::FORBIDDEN, |
| 642 |
"<Error><Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match</Message></Error>", |
| 643 |
) |
| 644 |
.into_response(); |
| 645 |
} |
| 646 |
if who != owner { |
| 647 |
return (StatusCode::FORBIDDEN, "<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>").into_response(); |
| 648 |
} |
| 649 |
let mut g = g.lock().unwrap(); |
| 650 |
match (parts.method.as_str(), key.is_empty()) { |
| 651 |
("PUT", false) => { |
| 652 |
g.objects.insert((bucket, key), bytes.to_vec()); |
| 653 |
StatusCode::OK.into_response() |
| 654 |
} |
| 655 |
("GET", false) | ("HEAD", false) => match g.objects.get(&(bucket, key)) { |
| 656 |
Some(b) => b.clone().into_response(), |
| 657 |
None => (StatusCode::NOT_FOUND, "<Error><Code>NoSuchKey</Code></Error>").into_response(), |
| 658 |
}, |
| 659 |
("DELETE", false) => { |
| 660 |
g.objects.remove(&(bucket, key)); |
| 661 |
StatusCode::NO_CONTENT.into_response() |
| 662 |
} |
| 663 |
("GET", true) => { |
| 664 |
let prefix = query.iter().find(|(k, _)| k == "prefix").map(|(_, v)| v.clone()).unwrap_or_default(); |
| 665 |
let delimited = query.iter().any(|(k, _)| k == "delimiter"); |
| 666 |
let (mut keys, mut prefixes) = (String::new(), std::collections::BTreeSet::new()); |
| 667 |
for (b, n) in g.objects.keys() { |
| 668 |
if *b != bucket || !n.starts_with(&prefix) { |
| 669 |
continue; |
| 670 |
} |
| 671 |
match n[prefix.len()..].find('/') { |
| 672 |
Some(i) if delimited => { |
| 673 |
prefixes.insert(format!("{prefix}{}", &n[prefix.len()..prefix.len() + i + 1])); |
| 674 |
} |
| 675 |
_ => keys.push_str(&format!("<Contents><Key>{n}</Key></Contents>")), |
| 676 |
} |
| 677 |
} |
| 678 |
let common: String = prefixes.iter().map(|p| format!("<CommonPrefixes><Prefix>{p}</Prefix></CommonPrefixes>")).collect(); |
| 679 |
format!("<ListBucketResult><Prefix>{prefix}</Prefix><IsTruncated>false</IsTruncated>{keys}{common}</ListBucketResult>") |
| 680 |
.into_response() |
| 681 |
} |
| 682 |
_ => StatusCode::METHOD_NOT_ALLOWED.into_response(), |
| 683 |
} |
| 684 |
} |
| 685 |
let app = axum::Router::new().fallback(handle).with_state(state.clone()); |
| 686 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 687 |
let port = listener.local_addr().unwrap().port(); |
| 688 |
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); |
| 689 |
(format!("http://127.0.0.1:{port}"), state) |
| 690 |
} |
| 691 |
|
| 692 |
async fn hub_aws(backups: &Path, aws: &str) -> Hub { |
| 693 |
hermetic(); |
| 694 |
let dir = tempfile::tempdir().unwrap(); |
| 695 |
let mut config = Config::load(dir.path()).unwrap(); |
| 696 |
|
| 697 |
config.registration.setup_code = "test-setup".into(); |
| 698 |
config.auth.push_tokens = vec!["site".into()]; |
| 699 |
config.backups.directories = vec![backups.to_path_buf()]; |
| 700 |
config.backups.aws_sts_url = aws.to_string(); |
| 701 |
config.backups.s3_url = format!("{aws}/s3/{{bucket}}"); |
| 702 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 703 |
let port = listener.local_addr().unwrap().port(); |
| 704 |
config.server.base_url = format!("http://127.0.0.1:{port}"); |
| 705 |
let app = oxygenhub::build(config).await.unwrap(); |
| 706 |
let router = oxygenhub::router(app.clone()); |
| 707 |
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
| 708 |
Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } |
| 709 |
} |
| 710 |
|
| 711 |
const ROLE: &str = "arn:aws:iam::123456789012:role/oxygenhub-backup"; |
| 712 |
|
| 713 |
fn s3(owner: &str, bucket: &str) -> Setting { |
| 714 |
let mut s = Setting::off(owner, "user"); |
| 715 |
s.every = "hourly".into(); |
| 716 |
s.destination = "s3".into(); |
| 717 |
s.target = bucket.into(); |
| 718 |
s.provider = ROLE.into(); |
| 719 |
s.region = "eu-west-2".into(); |
| 720 |
s.prefix = "oxygen hub".into(); |
| 721 |
s |
| 722 |
} |
| 723 |
|
| 724 |
|
| 725 |
|
| 726 |
|
| 727 |
|
| 728 |
|
| 729 |
#[tokio::test(flavor = "multi_thread")] |
| 730 |
async fn an_s3_backup_needs_no_key_and_restores_the_same_repository() { |
| 731 |
let (aws, seen) = fake_aws().await; |
| 732 |
let store = tempfile::tempdir().unwrap(); |
| 733 |
let h = hub_aws(store.path(), &aws).await; |
| 734 |
db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
| 735 |
db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap(); |
| 736 |
create(&h.app, "andy", "demo", hub_api::Visibility::Public).await; |
| 737 |
let work = tempfile::tempdir().unwrap(); |
| 738 |
push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n"), ("src/a b+c.rs", "fn main() {}\n")]); |
| 739 |
|
| 740 |
backups::save(&h.app.db, &s3("andy", "andy-backup")).await.unwrap(); |
| 741 |
let first = backups::run(&h.app, "andy", "now").await.unwrap(); |
| 742 |
assert_eq!((first.status.as_str(), first.repos), ("ok", 1), "{}", first.detail); |
| 743 |
{ |
| 744 |
let g = seen.lock().unwrap(); |
| 745 |
assert!( |
| 746 |
g.objects.keys().any(|(b, n)| b == "andy-backup" && n == &format!("oxygen hub/runs/{}/DONE", first.at)), |
| 747 |
"under the prefix" |
| 748 |
); |
| 749 |
assert_eq!(g.exchanged.first().map(|(s, a)| (s.as_str(), a.as_str())), Some(("backup:andy", "sts.amazonaws.com"))); |
| 750 |
} |
| 751 |
tokio::time::sleep(std::time::Duration::from_millis(1100)).await; |
| 752 |
let second = backups::run(&h.app, "andy", "now").await.unwrap(); |
| 753 |
assert_eq!((second.status.as_str(), second.sent), ("ok", 0), "{}", second.detail); |
| 754 |
let before = seen.lock().unwrap().objects.len(); |
| 755 |
let dest = backups::destination(&h.app, &s3("andy", "andy-backup")).unwrap(); |
| 756 |
tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap().unwrap(); |
| 757 |
assert_eq!(seen.lock().unwrap().objects.len(), before, "the probe cleans up"); |
| 758 |
|
| 759 |
let empty = tempfile::tempdir().unwrap(); |
| 760 |
let fresh = hub_aws(empty.path(), &aws).await; |
| 761 |
let dest: Arc<dyn Destination> = backups::destination(&fresh.app, &s3("andy", "andy-backup")).unwrap().into(); |
| 762 |
assert_eq!(backups::restore_into_hub(&fresh.app, dest, None).await.unwrap(), vec!["andy/demo (repos)"]); |
| 763 |
let name = RepoName::parse("andy/demo").unwrap(); |
| 764 |
assert_eq!(files(&fresh.app.repos.dir(&name)), files(&h.app.repos.dir(&name)), "byte for byte"); |
| 765 |
|
| 766 |
backups::save(&h.app.db, &s3("bob", "andy-backup")).await.unwrap(); |
| 767 |
let refused = backups::run(&h.app, "bob", "now").await.unwrap(); |
| 768 |
assert_eq!(refused.status, "failed"); |
| 769 |
assert!(refused.detail.contains("s3:PutObject") && refused.detail.contains(ROLE), "{}", refused.detail); |
| 770 |
} |
| 771 |
|
| 772 |
|
| 773 |
async fn api( |
| 774 |
app: &Shared, |
| 775 |
method: &str, |
| 776 |
path: &str, |
| 777 |
token: &str, |
| 778 |
body: Option<serde_json::Value>, |
| 779 |
) -> (axum::http::StatusCode, serde_json::Value) { |
| 780 |
use axum::body::Body; |
| 781 |
use http_body_util::BodyExt; |
| 782 |
use tower::ServiceExt; |
| 783 |
let mut req = |
| 784 |
axum::http::Request::builder().method(method).uri(format!("/api/v1{path}")).header("authorization", format!("Bearer {token}")); |
| 785 |
let body = match body { |
| 786 |
Some(b) => { |
| 787 |
req = req.header("content-type", "application/json"); |
| 788 |
Body::from(b.to_string()) |
| 789 |
} |
| 790 |
None => Body::empty(), |
| 791 |
}; |
| 792 |
let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
| 793 |
let st = res.status(); |
| 794 |
let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
| 795 |
(st, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)) |
| 796 |
} |
| 797 |
|
| 798 |
|
| 799 |
|
| 800 |
|
| 801 |
#[tokio::test(flavor = "multi_thread")] |
| 802 |
async fn backups_are_set_up_and_run_through_the_api() { |
| 803 |
let store = tempfile::tempdir().unwrap(); |
| 804 |
let h = hub(store.path()).await; |
| 805 |
let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
| 806 |
let bob = db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap(); |
| 807 |
db::accounts::create_org(&h.app.db, "club", "Club", andy.id).await.unwrap(); |
| 808 |
create(&h.app, "andy", "demo", hub_api::Visibility::Public).await; |
| 809 |
create(&h.app, "club", "game", hub_api::Visibility::Private).await; |
| 810 |
let token = |id: i64, scopes: &'static str| { |
| 811 |
let db = h.app.db.clone(); |
| 812 |
async move { |
| 813 |
let (t, hash) = oxygenhub::auth::new_token(); |
| 814 |
db::accounts::create_token(&db, id, "t", &hash, scopes, "", None).await.unwrap(); |
| 815 |
t |
| 816 |
} |
| 817 |
}; |
| 818 |
let (andy_t, bob_t, weak) = (token(andy.id, "repo:admin").await, token(bob.id, "repo:admin").await, token(andy.id, "repo:read").await); |
| 819 |
let dir = store.path().display().to_string(); |
| 820 |
|
| 821 |
let (st, empty) = api(&h.app, "GET", "/user/backups", &andy_t, None).await; |
| 822 |
assert_eq!(st, 200); |
| 823 |
assert!(empty["setting"].is_null() && empty["runs"].as_array().unwrap().is_empty(), "{empty}"); |
| 824 |
let (st, _) = api(&h.app, "POST", "/user/backups/run", &andy_t, None).await; |
| 825 |
assert_eq!(st, 409, "nothing saved yet"); |
| 826 |
let (st, _) = |
| 827 |
api(&h.app, "PUT", "/user/backups", &weak, Some(serde_json::json!({ "every": "hourly", "destination": "dir", "dir": dir }))).await; |
| 828 |
assert_eq!(st, 403, "repo:admin only"); |
| 829 |
let (st, bad) = api( |
| 830 |
&h.app, |
| 831 |
"PUT", |
| 832 |
"/user/backups", |
| 833 |
&andy_t, |
| 834 |
Some(serde_json::json!({ "every": "hourly", "destination": "gcs", "bucket": "andy-backup", "provider": "nope" })), |
| 835 |
) |
| 836 |
.await; |
| 837 |
assert_eq!(st, 400, "checked as the page checks it: {bad}"); |
| 838 |
let (st, saved) = api( |
| 839 |
&h.app, |
| 840 |
"PUT", |
| 841 |
"/user/backups", |
| 842 |
&andy_t, |
| 843 |
Some(serde_json::json!({ "every": "hourly", "destination": "dir", "dir": dir, "keep_days": 30 })), |
| 844 |
) |
| 845 |
.await; |
| 846 |
assert_eq!(st, 200, "{saved}"); |
| 847 |
assert_eq!((saved["setting"]["every"].as_str(), saved["place"].as_str()), (Some("hourly"), Some(format!("{dir}/andy").as_str()))); |
| 848 |
let (st, probe) = api(&h.app, "POST", "/user/backups/test", &andy_t, None).await; |
| 849 |
assert_eq!((st.as_u16(), probe["ok"].as_bool()), (200, Some(true)), "{probe}"); |
| 850 |
let (st, started) = api(&h.app, "POST", "/user/backups/run", &andy_t, None).await; |
| 851 |
assert_eq!(st, 202, "{started}"); |
| 852 |
let mut done = serde_json::Value::Null; |
| 853 |
for _ in 0..200 { |
| 854 |
let (_, runs) = api(&h.app, "GET", "/user/backups/runs", &andy_t, None).await; |
| 855 |
if runs[0]["status"].as_str().is_some_and(|s| s != "running") { |
| 856 |
done = runs[0].clone(); |
| 857 |
break; |
| 858 |
} |
| 859 |
tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
| 860 |
} |
| 861 |
assert_eq!((done["status"].as_str(), done["repos"].as_i64(), done["trigger"].as_str()), (Some("ok"), Some(1), Some("now")), "{done}"); |
| 862 |
|
| 863 |
|
| 864 |
sqlx::query("INSERT INTO backup_runs (owner, started_ms, finished_ms, status, at, trigger, detail) VALUES ('andy', 1, 2, 'failed', 'x', 'schedule', 'GCS refused')") |
| 865 |
.execute(&h.app.db) |
| 866 |
.await |
| 867 |
.unwrap(); |
| 868 |
tokio::time::sleep(std::time::Duration::from_millis(1100)).await; |
| 869 |
let again = backups::run(&h.app, "andy", "now").await.unwrap(); |
| 870 |
assert_eq!(again.status, "ok"); |
| 871 |
let told = |db: oxygenhub::db::Pool| async move { db::notify::inbox(&db, andy.id, 20).await.unwrap() }; |
| 872 |
assert!(told(h.app.db.clone()).await.iter().any(|n| n.kind == "backup.recovered" && n.title.contains("works again"))); |
| 873 |
assert_eq!(backups::daily_summary(&h.app, db::now_ms(), true).await.unwrap(), 1); |
| 874 |
let summary = told(h.app.db.clone()).await.into_iter().find(|n| n.kind == "backup.summary").expect("a summary"); |
| 875 |
assert!(summary.title.contains("Backups of andy today") && summary.title.contains("runs"), "{}", summary.title); |
| 876 |
|
| 877 |
|
| 878 |
let (st, _) = |
| 879 |
api(&h.app, "PUT", "/orgs/club/backups", &bob_t, Some(serde_json::json!({ "every": "hourly", "destination": "dir", "dir": dir }))) |
| 880 |
.await; |
| 881 |
assert_eq!(st, 404); |
| 882 |
let (st, org) = api( |
| 883 |
&h.app, |
| 884 |
"PUT", |
| 885 |
"/orgs/club/backups", |
| 886 |
&andy_t, |
| 887 |
Some(serde_json::json!({ "every": "daily", "destination": "dir", "dir": dir, "keep_days": 90 })), |
| 888 |
) |
| 889 |
.await; |
| 890 |
assert_eq!(st, 200, "{org}"); |
| 891 |
assert_eq!((org["setting"]["kind"].as_str(), org["setting"]["keep_days"].as_i64()), (Some("org"), Some(90))); |
| 892 |
let (st, _) = api(&h.app, "GET", "/orgs/club/backups", &bob_t, None).await; |
| 893 |
assert_eq!(st, 404); |
| 894 |
} |