Tahinli/forge
public0 watching
★ 0
| 1 | //! Page: backups (`/admin/backups`). Instance admins only; anyone else gets 404. |
| 2 | //! |
| 3 | //! Shows the `[backup]` settings (the healthcheck URL only as its host, since it may hold a token), |
| 4 | //! the last 20 backup and fsck runs, and two POST buttons that start a run in the background. |
| 5 | //! A button press is audited and redirects back here with a notice. |
| 6 | |
| 7 | use forge_core::store::backup_runs::{BackupRun, RunKind}; |
| 8 | use forge_core::tz::format_in_tz; |
| 9 | use forge_core::{AuditAction, Error, User}; |
| 10 | use serde::Deserialize; |
| 11 | use topcoat::Result; |
| 12 | use topcoat::context::{Cx, app_context}; |
| 13 | use topcoat::router::content::Form; |
| 14 | use topcoat::router::error::{forbidden, see_other}; |
| 15 | use topcoat::router::response::Response; |
| 16 | use topcoat::router::{page, query_params, route}; |
| 17 | use topcoat::view::{View, component, view}; |
| 18 | |
| 19 | use crate::audit::{self, Actor, Target}; |
| 20 | use crate::auth::check_csrf; |
| 21 | use crate::backup::scheduler::{configured_dir, start_manual}; |
| 22 | use crate::i18n::admin::{self as g, K}; |
| 23 | use crate::i18n::{self, Lang}; |
| 24 | use crate::pages::admin_users::admin_only; |
| 25 | use crate::pages::issues_list::stamp_unix; |
| 26 | use crate::state::AppState; |
| 27 | use crate::views::{enc, shell}; |
| 28 | |
| 29 | /// Runs shown on the page. |
| 30 | const RUN_LIMIT: u32 = 20; |
| 31 | |
| 32 | #[query_params(error = bad_request)] |
| 33 | pub(crate) struct NoticeQuery { |
| 34 | notice: Option<String>, |
| 35 | } |
| 36 | |
| 37 | #[derive(Deserialize)] |
| 38 | struct RunForm { |
| 39 | #[serde(default)] |
| 40 | csrf: String, |
| 41 | } |
| 42 | |
| 43 | /// One table row, already formatted. |
| 44 | struct RunRow { |
| 45 | kind: String, |
| 46 | trigger: String, |
| 47 | started: String, |
| 48 | duration: String, |
| 49 | size: String, |
| 50 | status: String, |
| 51 | detail: String, |
| 52 | } |
| 53 | |
| 54 | /// The settings block, already formatted. |
| 55 | struct Settings { |
| 56 | enabled: bool, |
| 57 | dir: String, |
| 58 | interval: String, |
| 59 | keep: String, |
| 60 | fsck_interval: String, |
| 61 | healthcheck: String, |
| 62 | } |
| 63 | |
| 64 | fn settings_view(lang: Lang, config: &forge_core::config::BackupConfig) -> Settings { |
| 65 | let healthcheck = if config.healthcheck_url.is_empty() { |
| 66 | g::t(lang, K::HealthcheckNotSet).to_owned() |
| 67 | } else { |
| 68 | match reqwest::Url::parse(&config.healthcheck_url) { |
| 69 | Ok(url) => g::healthcheck_host(lang, url.host_str()), |
| 70 | Err(_) => g::t(lang, K::HealthcheckUrlInvalid).to_owned(), |
| 71 | } |
| 72 | }; |
| 73 | Settings { |
| 74 | enabled: config.enabled(), |
| 75 | dir: config.dir.display().to_string(), |
| 76 | interval: g::every_hours(lang, config.interval_hours), |
| 77 | keep: g::newest_sets(lang, config.keep), |
| 78 | fsck_interval: g::every_days(lang, config.fsck_interval_days), |
| 79 | healthcheck, |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | /// `"512 B"`, `"12.3 KiB"`, `"4.0 MiB"` and so on. |
| 84 | fn human_bytes(bytes: u64) -> String { |
| 85 | const UNITS: [&str; 4] = ["KiB", "MiB", "GiB", "TiB"]; |
| 86 | if bytes < 1024 { |
| 87 | return format!("{bytes} B"); |
| 88 | } |
| 89 | let mut value = bytes as f64 / 1024.0; |
| 90 | let mut unit = 0; |
| 91 | while value >= 1024.0 && unit + 1 < UNITS.len() { |
| 92 | value /= 1024.0; |
| 93 | unit += 1; |
| 94 | } |
| 95 | format!("{value:.1} {}", UNITS[unit]) |
| 96 | } |
| 97 | |
| 98 | fn duration_text(run: &BackupRun) -> String { |
| 99 | let start = stamp_unix(&run.started_at); |
| 100 | let Some(end) = run.finished_at.as_deref().map(stamp_unix) else { |
| 101 | return "-".to_owned(); |
| 102 | }; |
| 103 | let secs = (end - start).max(0); |
| 104 | if secs < 60 { |
| 105 | format!("{secs}s") |
| 106 | } else { |
| 107 | format!("{}m {}s", secs / 60, secs % 60) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | fn run_row(run: &BackupRun, tz: &str, lang: Lang) -> RunRow { |
| 112 | let (status, detail) = match (run.finished_at.is_some(), run.ok) { |
| 113 | (false, _) => (g::t(lang, K::StatusInProgress).to_owned(), String::new()), |
| 114 | (true, Some(true)) => (g::t(lang, K::StatusOk).to_owned(), String::new()), |
| 115 | (true, _) => ( |
| 116 | g::t(lang, K::StatusFailed).to_owned(), |
| 117 | run.error.clone().unwrap_or_default(), |
| 118 | ), |
| 119 | }; |
| 120 | RunRow { |
| 121 | kind: g::kind_name(lang, run.kind).to_owned(), |
| 122 | trigger: g::trigger_name(lang, run.trigger).to_owned(), |
| 123 | started: format_in_tz(stamp_unix(&run.started_at), tz), |
| 124 | duration: duration_text(run), |
| 125 | size: run.bytes.map(human_bytes).unwrap_or_default(), |
| 126 | status, |
| 127 | detail, |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | #[component] |
| 132 | async fn backups_view( |
| 133 | lang: Lang, |
| 134 | csrf: String, |
| 135 | settings: Settings, |
| 136 | rows: Vec<RunRow>, |
| 137 | running: Option<RunKind>, |
| 138 | notice: Option<String>, |
| 139 | ) -> Result<impl View> { |
| 140 | Ok(view! { |
| 141 | <section class="page-head"> |
| 142 | <h1>(g::t(lang, K::Backups))</h1> |
| 143 | <p class="muted">(g::t(lang, K::BackupsIntro))</p> |
| 144 | </section> |
| 145 | if let Some(message) = notice { |
| 146 | <section class="card notice" role="status"> |
| 147 | <p>(message.as_str())</p> |
| 148 | </section> |
| 149 | } |
| 150 | <section class="card"> |
| 151 | <h2>(g::t(lang, K::Settings))</h2> |
| 152 | if settings.enabled { |
| 153 | <dl class="details"> |
| 154 | <dt>(g::t(lang, K::Directory))</dt><dd><code>(settings.dir.as_str())</code></dd> |
| 155 | <dt>(g::t(lang, K::BackupInterval))</dt><dd>(settings.interval.as_str())</dd> |
| 156 | <dt>(g::t(lang, K::Retention))</dt><dd>(settings.keep.as_str())</dd> |
| 157 | <dt>(g::t(lang, K::Fsck))</dt><dd>(settings.fsck_interval.as_str())</dd> |
| 158 | <dt>(g::t(lang, K::Healthcheck))</dt><dd>(settings.healthcheck.as_str())</dd> |
| 159 | </dl> |
| 160 | } else { |
| 161 | <p>(g::t(lang, K::ScheduledBackupsOff))</p> |
| 162 | } |
| 163 | if let Some(kind) = running { |
| 164 | <p class="muted">(g::running_line(lang, kind))</p> |
| 165 | } |
| 166 | <div class="actions"> |
| 167 | <form method="post" action="/admin/backups/run"> |
| 168 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 169 | <button type="submit" class="primary">(g::t(lang, K::RunBackupNow))</button> |
| 170 | </form> |
| 171 | <form method="post" action="/admin/backups/fsck"> |
| 172 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 173 | <button type="submit">(g::t(lang, K::RunFsckNow))</button> |
| 174 | </form> |
| 175 | </div> |
| 176 | </section> |
| 177 | <section class="card"> |
| 178 | <h2>(g::t(lang, K::RecentRuns))</h2> |
| 179 | if rows.is_empty() { |
| 180 | <p class="muted">(g::t(lang, K::NoRunsYet))</p> |
| 181 | } else { |
| 182 | <div class="table-wrap"> |
| 183 | <table class="table"> |
| 184 | <thead> |
| 185 | <tr> |
| 186 | <th>(g::t(lang, K::TableKind))</th> |
| 187 | <th>(g::t(lang, K::TableTrigger))</th> |
| 188 | <th>(g::t(lang, K::TableStarted))</th> |
| 189 | <th>(g::t(lang, K::TableDuration))</th> |
| 190 | <th>(g::t(lang, K::TableSize))</th> |
| 191 | <th>(g::t(lang, K::TableResult))</th> |
| 192 | </tr> |
| 193 | </thead> |
| 194 | <tbody> |
| 195 | for row in rows.into_iter() { |
| 196 | <tr> |
| 197 | <td>(row.kind.as_str())</td> |
| 198 | <td>(row.trigger.as_str())</td> |
| 199 | <td class="small">(row.started.as_str())</td> |
| 200 | <td class="small">(row.duration.as_str())</td> |
| 201 | <td class="small">(row.size.as_str())</td> |
| 202 | <td> |
| 203 | (row.status.as_str()) |
| 204 | if !row.detail.is_empty() { |
| 205 | <div class="small muted">(row.detail.as_str())</div> |
| 206 | } |
| 207 | </td> |
| 208 | </tr> |
| 209 | } |
| 210 | </tbody> |
| 211 | </table> |
| 212 | </div> |
| 213 | } |
| 214 | </section> |
| 215 | }) |
| 216 | } |
| 217 | |
| 218 | #[page("/admin/backups")] |
| 219 | async fn backups_page(cx: &Cx) -> Result<impl View> { |
| 220 | let viewer = admin_only(cx).await?; |
| 221 | let query = query_params::<NoticeQuery>(cx)?; |
| 222 | let state = app_context::<AppState>(cx); |
| 223 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 224 | let runs = state.store.recent_runs(RUN_LIMIT).await?; |
| 225 | let tz = viewer.0.timezone.as_str(); |
| 226 | let rows: Vec<RunRow> = runs.iter().map(|run| run_row(run, tz, lang)).collect(); |
| 227 | let running = state.jobs.running(); |
| 228 | let settings = settings_view(lang, &state.config.backup); |
| 229 | let csrf = viewer.1.csrf_token.clone(); |
| 230 | let title = g::t(lang, K::Backups); |
| 231 | Ok(view! { |
| 232 | shell(title: title, viewer: Some(&viewer), backups_view(lang: lang, csrf: csrf, settings: settings, rows: rows, running: running, notice: query.notice.clone())) |
| 233 | }) |
| 234 | } |
| 235 | |
| 236 | #[route(POST "/admin/backups/run")] |
| 237 | async fn backups_run(cx: &Cx, Form(form): Form<RunForm>) -> Result<Response> { |
| 238 | start_run(cx, form.csrf, RunKind::Backup).await |
| 239 | } |
| 240 | |
| 241 | #[route(POST "/admin/backups/fsck")] |
| 242 | async fn backups_fsck(cx: &Cx, Form(form): Form<RunForm>) -> Result<Response> { |
| 243 | start_run(cx, form.csrf, RunKind::Fsck).await |
| 244 | } |
| 245 | |
| 246 | /// Starts a run in the background, audits the request and redirects back with a notice. |
| 247 | async fn start_run(cx: &Cx, csrf: String, kind: RunKind) -> Result<Response> { |
| 248 | let viewer = admin_only(cx).await?; |
| 249 | if !check_csrf(&viewer.1, &csrf) { |
| 250 | return Err(forbidden().into()); |
| 251 | } |
| 252 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 253 | let state = app_context::<AppState>(cx); |
| 254 | let notice = match launch(cx, state, &viewer.0, kind, lang).await { |
| 255 | Ok(()) => g::started_notice(lang, kind), |
| 256 | Err(e) => match e { |
| 257 | Error::Conflict(message) => message, |
| 258 | other => other.to_string(), |
| 259 | }, |
| 260 | }; |
| 261 | let location = format!("/admin/backups?notice={}", enc(¬ice)); |
| 262 | Err(see_other(&location).into()) |
| 263 | } |
| 264 | |
| 265 | /// Claims the run slot, records the audit entry and starts the run. Refusals come back as errors. |
| 266 | async fn launch( |
| 267 | cx: &Cx, |
| 268 | state: &AppState, |
| 269 | user: &User, |
| 270 | kind: RunKind, |
| 271 | lang: Lang, |
| 272 | ) -> forge_core::Result<()> { |
| 273 | if !state.config.backup.enabled() { |
| 274 | return Err(Error::Config( |
| 275 | g::t(lang, K::ErrBackupsNotConfigured).to_owned(), |
| 276 | )); |
| 277 | } |
| 278 | // Refuse a bad directory here, not in the background task. |
| 279 | configured_dir(state)?; |
| 280 | let guard = state.jobs.claim(kind)?; |
| 281 | let (action, target_kind) = match kind { |
| 282 | RunKind::Backup => (AuditAction::BackupStarted, "backup"), |
| 283 | RunKind::Fsck => (AuditAction::FsckStarted, "fsck"), |
| 284 | }; |
| 285 | audit::record( |
| 286 | &state.store, |
| 287 | Some(cx), |
| 288 | Actor::User(user), |
| 289 | action, |
| 290 | Target { |
| 291 | kind: target_kind, |
| 292 | id: "", |
| 293 | name: "", |
| 294 | }, |
| 295 | serde_json::json!({ "trigger": "manual" }), |
| 296 | ) |
| 297 | .await; |
| 298 | start_manual(state, guard) |
| 299 | } |
| 300 | |
| 301 | #[cfg(test)] |
| 302 | mod tests { |
| 303 | use super::*; |
| 304 | use forge_core::model::Visibility; |
| 305 | use forge_core::store::backup_runs::RunTrigger; |
| 306 | use topcoat::router::request::Request; |
| 307 | use topcoat::router::{Body, Router, to_bytes}; |
| 308 | |
| 309 | use crate::auth::SESSION_COOKIE; |
| 310 | use crate::test_support::{app_router, create_session, test_state}; |
| 311 | |
| 312 | const ADMIN_TOKEN: &str = "backups-admin-session"; |
| 313 | const USER_TOKEN: &str = "backups-user-session"; |
| 314 | const CSRF: &str = "backups-csrf"; |
| 315 | |
| 316 | struct Fixture { |
| 317 | _dir: tempfile::TempDir, |
| 318 | router: Router, |
| 319 | state: AppState, |
| 320 | } |
| 321 | |
| 322 | /// An instance with an admin, a plain user, one repo, and backups configured under the temp |
| 323 | /// directory. |
| 324 | async fn fixture(configured: bool) -> Fixture { |
| 325 | let dir = tempfile::tempdir().expect("tempdir"); |
| 326 | let data_dir = dir.path().join("data"); |
| 327 | std::fs::create_dir_all(&data_dir).expect("data dir"); |
| 328 | let mut state = test_state(&data_dir).await; |
| 329 | let mut config = crate::test_support::test_config(&data_dir); |
| 330 | if configured { |
| 331 | config.backup.dir = dir.path().join("out"); |
| 332 | config.backup.keep = 2; |
| 333 | } |
| 334 | state.config = std::sync::Arc::new(config); |
| 335 | let admin = state |
| 336 | .store |
| 337 | .create_user("root", None, "h", true) |
| 338 | .await |
| 339 | .expect("admin"); |
| 340 | let user = state |
| 341 | .store |
| 342 | .create_user("plain", None, "h", false) |
| 343 | .await |
| 344 | .expect("user"); |
| 345 | create_session(&state.store, &admin.id, ADMIN_TOKEN, CSRF).await; |
| 346 | create_session(&state.store, &user.id, USER_TOKEN, CSRF).await; |
| 347 | let repo = state |
| 348 | .store |
| 349 | .create_repo(&admin.id, "demo", "", Visibility::Private, "main") |
| 350 | .await |
| 351 | .expect("repo"); |
| 352 | // fsck reads the repo from disk, so it must exist for the run to pass. |
| 353 | let path = forge_git::repo::repo_path(&data_dir, &repo.id).expect("path"); |
| 354 | forge_git::repo::init_bare(&state.git, &path, "main") |
| 355 | .await |
| 356 | .expect("init"); |
| 357 | let router = app_router(state.clone()); |
| 358 | Fixture { |
| 359 | _dir: dir, |
| 360 | router, |
| 361 | state, |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | async fn send( |
| 366 | router: &Router, |
| 367 | method: &str, |
| 368 | uri: &str, |
| 369 | token: Option<&str>, |
| 370 | form: Option<String>, |
| 371 | ) -> (u16, String) { |
| 372 | let mut builder = Request::builder().method(method).uri(uri); |
| 373 | if let Some(token) = token { |
| 374 | builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}")); |
| 375 | } |
| 376 | let body = match form { |
| 377 | Some(form) => { |
| 378 | builder = builder.header("content-type", "application/x-www-form-urlencoded"); |
| 379 | Body::from(form) |
| 380 | } |
| 381 | None => Body::empty(), |
| 382 | }; |
| 383 | let res = router.handle(builder.body(body).expect("request")).await; |
| 384 | let status = res.status().as_u16(); |
| 385 | let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); |
| 386 | (status, String::from_utf8_lossy(&bytes).into_owned()) |
| 387 | } |
| 388 | |
| 389 | #[tokio::test] |
| 390 | async fn page_is_404_for_anonymous_and_non_admins() { |
| 391 | let fx = fixture(true).await; |
| 392 | let (anon, _) = send(&fx.router, "GET", "/admin/backups", None, None).await; |
| 393 | assert_eq!(anon, 404); |
| 394 | let (user, _) = send(&fx.router, "GET", "/admin/backups", Some(USER_TOKEN), None).await; |
| 395 | assert_eq!(user, 404); |
| 396 | let (posted, _) = send( |
| 397 | &fx.router, |
| 398 | "POST", |
| 399 | "/admin/backups/run", |
| 400 | Some(USER_TOKEN), |
| 401 | Some(format!("csrf={CSRF}")), |
| 402 | ) |
| 403 | .await; |
| 404 | assert_eq!(posted, 404); |
| 405 | } |
| 406 | |
| 407 | #[tokio::test] |
| 408 | async fn page_lists_runs_and_hides_the_healthcheck_token() { |
| 409 | let fx = fixture(true).await; |
| 410 | let mut config = (*fx.state.config).clone(); |
| 411 | config.backup.healthcheck_url = "https://hc.example.test/ping/SECRET-TOKEN".into(); |
| 412 | let mut state = fx.state.clone(); |
| 413 | state.config = std::sync::Arc::new(config); |
| 414 | let router = app_router(state.clone()); |
| 415 | |
| 416 | let id = state |
| 417 | .store |
| 418 | .start_run(RunKind::Backup, RunTrigger::Schedule) |
| 419 | .await |
| 420 | .expect("start"); |
| 421 | state |
| 422 | .store |
| 423 | .finish_run(&id, false, None, None, Some("disk full")) |
| 424 | .await |
| 425 | .expect("finish"); |
| 426 | |
| 427 | let (status, body) = send(&router, "GET", "/admin/backups", Some(ADMIN_TOKEN), None).await; |
| 428 | assert_eq!(status, 200); |
| 429 | assert!(body.contains("schedule")); |
| 430 | assert!(body.contains("disk full")); |
| 431 | assert!(body.contains("hc.example.test")); |
| 432 | assert!( |
| 433 | !body.contains("SECRET-TOKEN"), |
| 434 | "the healthcheck token must not be shown" |
| 435 | ); |
| 436 | assert!(body.contains("Run backup now")); |
| 437 | assert!(body.contains("Run fsck now")); |
| 438 | } |
| 439 | |
| 440 | #[tokio::test] |
| 441 | async fn post_starts_a_run_audits_it_and_records_it() { |
| 442 | let fx = fixture(true).await; |
| 443 | // Refused without the CSRF token. |
| 444 | let (bad, _) = send( |
| 445 | &fx.router, |
| 446 | "POST", |
| 447 | "/admin/backups/fsck", |
| 448 | Some(ADMIN_TOKEN), |
| 449 | Some("csrf=wrong".into()), |
| 450 | ) |
| 451 | .await; |
| 452 | assert_eq!(bad, 403); |
| 453 | |
| 454 | let (status, _) = send( |
| 455 | &fx.router, |
| 456 | "POST", |
| 457 | "/admin/backups/fsck", |
| 458 | Some(ADMIN_TOKEN), |
| 459 | Some(format!("csrf={CSRF}")), |
| 460 | ) |
| 461 | .await; |
| 462 | assert_eq!(status, 303); |
| 463 | |
| 464 | // Wait for the background fsck to finish and record itself. |
| 465 | let mut runs = Vec::new(); |
| 466 | for _ in 0..500 { |
| 467 | runs = fx.state.store.recent_runs(5).await.expect("runs"); |
| 468 | if runs.iter().any(|r| r.finished_at.is_some()) { |
| 469 | break; |
| 470 | } |
| 471 | tokio::time::sleep(std::time::Duration::from_millis(20)).await; |
| 472 | } |
| 473 | assert_eq!(runs.len(), 1, "{runs:?}"); |
| 474 | assert_eq!(runs[0].kind, RunKind::Fsck); |
| 475 | assert_eq!(runs[0].trigger, RunTrigger::Manual); |
| 476 | assert_eq!(runs[0].ok, Some(true)); |
| 477 | |
| 478 | let audit = fx |
| 479 | .state |
| 480 | .store |
| 481 | .list_audit(&Default::default(), None, 10) |
| 482 | .await |
| 483 | .expect("audit"); |
| 484 | assert!(audit.iter().any(|e| e.action == AuditAction::FsckStarted)); |
| 485 | |
| 486 | let (_, page) = send(&fx.router, "GET", "/admin/backups", Some(ADMIN_TOKEN), None).await; |
| 487 | assert!(page.contains("fsck")); |
| 488 | } |
| 489 | |
| 490 | #[tokio::test] |
| 491 | async fn a_backup_run_while_one_is_active_says_already_running() { |
| 492 | let fx = fixture(true).await; |
| 493 | let _held = fx.state.jobs.claim(RunKind::Backup).expect("claim"); |
| 494 | let (status, _) = send( |
| 495 | &fx.router, |
| 496 | "POST", |
| 497 | "/admin/backups/run", |
| 498 | Some(ADMIN_TOKEN), |
| 499 | Some(format!("csrf={CSRF}")), |
| 500 | ) |
| 501 | .await; |
| 502 | assert_eq!(status, 303); |
| 503 | let (_, page) = send( |
| 504 | &fx.router, |
| 505 | "GET", |
| 506 | "/admin/backups?notice=already%20running%3A%20x", |
| 507 | Some(ADMIN_TOKEN), |
| 508 | None, |
| 509 | ) |
| 510 | .await; |
| 511 | assert!(page.contains("already running")); |
| 512 | assert!( |
| 513 | fx.state |
| 514 | .store |
| 515 | .recent_runs(5) |
| 516 | .await |
| 517 | .expect("runs") |
| 518 | .is_empty() |
| 519 | ); |
| 520 | } |
| 521 | |
| 522 | #[tokio::test] |
| 523 | async fn unconfigured_instance_refuses_manual_runs() { |
| 524 | let fx = fixture(false).await; |
| 525 | let (status, _) = send( |
| 526 | &fx.router, |
| 527 | "POST", |
| 528 | "/admin/backups/run", |
| 529 | Some(ADMIN_TOKEN), |
| 530 | Some(format!("csrf={CSRF}")), |
| 531 | ) |
| 532 | .await; |
| 533 | assert_eq!(status, 303); |
| 534 | assert!( |
| 535 | fx.state |
| 536 | .store |
| 537 | .recent_runs(5) |
| 538 | .await |
| 539 | .expect("runs") |
| 540 | .is_empty() |
| 541 | ); |
| 542 | assert_eq!(fx.state.jobs.running(), None); |
| 543 | } |
| 544 | |
| 545 | #[test] |
| 546 | fn human_bytes_scales() { |
| 547 | assert_eq!(human_bytes(512), "512 B"); |
| 548 | assert_eq!(human_bytes(2048), "2.0 KiB"); |
| 549 | assert_eq!(human_bytes(3 * 1024 * 1024), "3.0 MiB"); |
| 550 | } |
| 551 | } |