| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | use forge_core::AuditAction; |
| 10 | use forge_core::ci::CiRunner; |
| 11 | use serde::Deserialize; |
| 12 | use topcoat::Result; |
| 13 | use topcoat::context::{Cx, app_context}; |
| 14 | use topcoat::router::content::Form; |
| 15 | use topcoat::router::error::{SeeOther, forbidden, see_other}; |
| 16 | use topcoat::router::request::uri; |
| 17 | use topcoat::router::{page, path_param, route}; |
| 18 | use topcoat::view::{View, component, view}; |
| 19 | |
| 20 | use crate::audit::{self, Actor, Target}; |
| 21 | use crate::auth::check_csrf; |
| 22 | use crate::i18n::admin::{self as g, K}; |
| 23 | use crate::i18n::{self, Key, Lang}; |
| 24 | use crate::pages::admin_users::admin_only; |
| 25 | use crate::pages::repo_mirrors::stamp_in_tz; |
| 26 | use crate::state::AppState; |
| 27 | use crate::views::{enc, shell}; |
| 28 | |
| 29 | path_param!(runner_id); |
| 30 | |
| 31 | |
| 32 | const NOTICES: [(&str, K, bool); 5] = [ |
| 33 | ("disabled", K::RunnerDisabledNotice, false), |
| 34 | ("enabled", K::RunnerEnabledNotice, false), |
| 35 | ("deleted", K::RunnerDeletedNotice, false), |
| 36 | ("missing", K::NoRunnerWithId, true), |
| 37 | ("token-failed", K::TokenCreateFailed, true), |
| 38 | ]; |
| 39 | |
| 40 | #[derive(Deserialize)] |
| 41 | struct CsrfForm { |
| 42 | #[serde(default)] |
| 43 | csrf: String, |
| 44 | } |
| 45 | |
| 46 | #[derive(Deserialize)] |
| 47 | struct DisableForm { |
| 48 | #[serde(default)] |
| 49 | csrf: String, |
| 50 | #[serde(default)] |
| 51 | disabled: String, |
| 52 | } |
| 53 | |
| 54 | |
| 55 | struct RunnerRow { |
| 56 | name: String, |
| 57 | labels: String, |
| 58 | last_seen: String, |
| 59 | disabled: bool, |
| 60 | toggle_action: String, |
| 61 | delete_action: String, |
| 62 | } |
| 63 | |
| 64 | fn runner_row(runner: &CiRunner, tz: &str, lang: Lang) -> RunnerRow { |
| 65 | RunnerRow { |
| 66 | name: runner.name.clone(), |
| 67 | labels: runner.labels.join(", "), |
| 68 | last_seen: runner |
| 69 | .last_seen_at |
| 70 | .as_deref() |
| 71 | .map(|stamp| stamp_in_tz(stamp, tz)) |
| 72 | .unwrap_or_else(|| i18n::t(lang, Key::Never).to_owned()), |
| 73 | disabled: runner.disabled, |
| 74 | toggle_action: format!("/admin/runners/{}/disabled", enc(&runner.id)), |
| 75 | delete_action: format!("/admin/runners/{}/delete", enc(&runner.id)), |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | |
| 80 | fn notice_from_query(cx: &Cx, lang: Lang) -> Option<(&'static str, bool)> { |
| 81 | let query = uri(cx).query()?; |
| 82 | let word = form_urlencoded::parse(query.as_bytes()) |
| 83 | .find(|(key, _)| key == "notice") |
| 84 | .map(|(_, value)| value.into_owned())?; |
| 85 | NOTICES |
| 86 | .iter() |
| 87 | .find(|(name, _, _)| *name == word) |
| 88 | .map(|(_, key, is_error)| (g::t(lang, *key), *is_error)) |
| 89 | } |
| 90 | |
| 91 | fn back_to_list(notice: &str) -> SeeOther { |
| 92 | see_other(format!("/admin/runners?notice={}", enc(notice))) |
| 93 | } |
| 94 | |
| 95 | |
| 96 | fn register_command(base_url: &str, token: &str) -> String { |
| 97 | format!( |
| 98 | "forge runner register --url {} --token {token} --name <name> --labels linux", |
| 99 | base_url.trim_end_matches('/') |
| 100 | ) |
| 101 | } |
| 102 | |
| 103 | #[component] |
| 104 | async fn runners_nav(lang: Lang) -> Result<impl View> { |
| 105 | Ok(view! { |
| 106 | <p><a href="/admin/users">(g::t(lang, K::Users))</a> " · " <a href="/admin/audit">(g::t(lang, K::AuditLog))</a> " · " <a href="/admin/backups">(g::t(lang, K::Backups))</a> " · " <a href="/admin/replication">(g::t(lang, K::Replication))</a> " · " <a href="/admin/mail">(g::t(lang, K::Mail))</a> " · " <a href="/admin/runners" aria-current="page">(g::t(lang, K::Runners))</a></p> |
| 107 | }) |
| 108 | } |
| 109 | |
| 110 | #[component] |
| 111 | async fn runners_view( |
| 112 | lang: Lang, |
| 113 | csrf: String, |
| 114 | rows: Vec<RunnerRow>, |
| 115 | notice: Option<(&'static str, bool)>, |
| 116 | note: String, |
| 117 | ) -> Result<impl View> { |
| 118 | Ok(view! { |
| 119 | <section class="page-head"> |
| 120 | <h1>(g::t(lang, K::CiRunners))</h1> |
| 121 | runners_nav(lang: lang) |
| 122 | </section> |
| 123 | if let Some((text, is_error)) = notice { |
| 124 | if is_error { |
| 125 | <section class="card notice alert" role="alert">(text)</section> |
| 126 | } else { |
| 127 | <section class="card notice" role="status">(text)</section> |
| 128 | } |
| 129 | } |
| 130 | <section class="card"> |
| 131 | <h2>(g::t(lang, K::Runners))</h2> |
| 132 | if rows.is_empty() { |
| 133 | <p class="muted">(g::t(lang, K::NoRunnersYet))</p> |
| 134 | } else { |
| 135 | <div class="table-wrap"> |
| 136 | <table class="table runners"> |
| 137 | <thead> |
| 138 | <tr> |
| 139 | <th>(g::t(lang, K::TableName))</th> |
| 140 | <th>(g::t(lang, K::TableLabels))</th> |
| 141 | <th>(g::t(lang, K::LastSeen))</th> |
| 142 | <th>(g::t(lang, K::TableState))</th> |
| 143 | <th></th> |
| 144 | </tr> |
| 145 | </thead> |
| 146 | <tbody> |
| 147 | for row in rows.iter() { |
| 148 | <tr> |
| 149 | <td>(row.name.as_str())</td> |
| 150 | <td><code>(row.labels.as_str())</code></td> |
| 151 | <td class="small">(row.last_seen.as_str())</td> |
| 152 | <td>(if row.disabled { g::t(lang, K::Disabled) } else { g::t(lang, K::Enabled) })</td> |
| 153 | <td> |
| 154 | <div class="actions"> |
| 155 | <form method="post" action=(row.toggle_action.as_str()) class="inline-form"> |
| 156 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 157 | <input type="hidden" name="disabled" value=(if row.disabled { "0" } else { "1" })> |
| 158 | <button type="submit">(if row.disabled { g::t(lang, K::Enable) } else { g::t(lang, K::Disable) })</button> |
| 159 | </form> |
| 160 | <form method="post" action=(row.delete_action.as_str()) class="inline-form"> |
| 161 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 162 | <button type="submit" class="danger">(i18n::t(lang, Key::Delete))</button> |
| 163 | </form> |
| 164 | </div> |
| 165 | </td> |
| 166 | </tr> |
| 167 | } |
| 168 | </tbody> |
| 169 | </table> |
| 170 | </div> |
| 171 | } |
| 172 | </section> |
| 173 | <section class="card"> |
| 174 | <h2>(g::t(lang, K::AddARunner))</h2> |
| 175 | <p class="muted small">(note.as_str())</p> |
| 176 | <form method="post" action="/admin/runners/tokens" class="inline-form"> |
| 177 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 178 | <button type="submit" class="primary">(g::t(lang, K::CreateRegistrationToken))</button> |
| 179 | </form> |
| 180 | </section> |
| 181 | }) |
| 182 | } |
| 183 | |
| 184 | #[component] |
| 185 | async fn token_view( |
| 186 | lang: Lang, |
| 187 | token: String, |
| 188 | command: String, |
| 189 | expiry: String, |
| 190 | ) -> Result<impl View> { |
| 191 | Ok(view! { |
| 192 | <section class="page-head"> |
| 193 | <h1>(g::t(lang, K::RegistrationToken))</h1> |
| 194 | runners_nav(lang: lang) |
| 195 | </section> |
| 196 | <section class="card notice" role="status"> |
| 197 | <p><strong>(g::t(lang, K::CopyTokenNow))</strong></p> |
| 198 | <p>(expiry.as_str())</p> |
| 199 | </section> |
| 200 | <section class="card"> |
| 201 | <h2>(g::t(lang, K::Token))</h2> |
| 202 | <pre><code>(token.as_str())</code></pre> |
| 203 | <h2>(g::t(lang, K::RegisterARunner))</h2> |
| 204 | <p class="muted small">(g::t(lang, K::ReplacePrefix))<code>"<name>"</code>(g::t(lang, K::ReplaceSuffix))</p> |
| 205 | <pre><code>(command.as_str())</code></pre> |
| 206 | <p><a href="/admin/runners">(g::t(lang, K::BackToRunners))</a></p> |
| 207 | </section> |
| 208 | }) |
| 209 | } |
| 210 | |
| 211 | #[page("/admin/runners")] |
| 212 | async fn runners_page(cx: &Cx) -> Result<impl View> { |
| 213 | let viewer = admin_only(cx).await?; |
| 214 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 215 | let state = app_context::<AppState>(cx); |
| 216 | let tz = viewer.0.timezone.clone(); |
| 217 | let rows: Vec<RunnerRow> = state |
| 218 | .store |
| 219 | .list_runners() |
| 220 | .await? |
| 221 | .iter() |
| 222 | .map(|runner| runner_row(runner, &tz, lang)) |
| 223 | .collect(); |
| 224 | let csrf = viewer.1.csrf_token.clone(); |
| 225 | let notice = notice_from_query(cx, lang); |
| 226 | let note = g::token_ttl_note( |
| 227 | lang, |
| 228 | &g::hours_text(lang, state.config.ci.registration_token_hours), |
| 229 | ); |
| 230 | let title = g::t(lang, K::CiRunners); |
| 231 | Ok(view! { |
| 232 | shell(title: title, viewer: Some(&viewer), runners_view(lang: lang, csrf: csrf, rows: rows, notice: notice, note: note)) |
| 233 | }) |
| 234 | } |
| 235 | |
| 236 | |
| 237 | |
| 238 | #[page(POST "/admin/runners/tokens")] |
| 239 | async fn runner_token_create(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<impl View> { |
| 240 | let viewer = admin_only(cx).await?; |
| 241 | if !check_csrf(&viewer.1, &form.csrf) { |
| 242 | return Err(forbidden().into()); |
| 243 | } |
| 244 | let state = app_context::<AppState>(cx); |
| 245 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 246 | let ttl_hours = state.config.ci.registration_token_hours; |
| 247 | let token = match state |
| 248 | .store |
| 249 | .create_registration_token(&viewer.0.id, ttl_hours) |
| 250 | .await |
| 251 | { |
| 252 | Ok(token) => token, |
| 253 | Err(e) => { |
| 254 | tracing::warn!(error = %e, "registration token not created"); |
| 255 | return Err(back_to_list("token-failed").into()); |
| 256 | } |
| 257 | }; |
| 258 | audit::record( |
| 259 | &state.store, |
| 260 | Some(cx), |
| 261 | Actor::User(&viewer.0), |
| 262 | AuditAction::CiRegistrationTokenCreated, |
| 263 | Target { |
| 264 | kind: "ci_runner_token", |
| 265 | id: "", |
| 266 | name: "", |
| 267 | }, |
| 268 | serde_json::json!({ "ttl_hours": ttl_hours }), |
| 269 | ) |
| 270 | .await; |
| 271 | let command = register_command(&state.config.base_url, &token); |
| 272 | let expiry = g::token_expiry_line(lang, &g::hours_text(lang, ttl_hours)); |
| 273 | let title = g::t(lang, K::RegistrationToken); |
| 274 | Ok(view! { |
| 275 | shell(title: title, viewer: Some(&viewer), token_view(lang: lang, token: token, command: command, expiry: expiry)) |
| 276 | }) |
| 277 | } |
| 278 | |
| 279 | |
| 280 | async fn runner_named(state: &AppState, id: &str) -> forge_core::Result<Option<CiRunner>> { |
| 281 | Ok(state |
| 282 | .store |
| 283 | .list_runners() |
| 284 | .await? |
| 285 | .into_iter() |
| 286 | .find(|runner| runner.id == id)) |
| 287 | } |
| 288 | |
| 289 | |
| 290 | #[route(POST "/admin/runners/{runner_id}/disabled")] |
| 291 | async fn runner_set_disabled(cx: &Cx, Form(form): Form<DisableForm>) -> Result<SeeOther> { |
| 292 | let viewer = admin_only(cx).await?; |
| 293 | if !check_csrf(&viewer.1, &form.csrf) { |
| 294 | return Err(forbidden().into()); |
| 295 | } |
| 296 | let id = path_param::<RunnerId>(cx); |
| 297 | let state = app_context::<AppState>(cx); |
| 298 | if runner_named(state, id).await?.is_none() { |
| 299 | return Ok(back_to_list("missing")); |
| 300 | } |
| 301 | let disabled = form.disabled == "1"; |
| 302 | state.store.set_runner_disabled(id, disabled).await?; |
| 303 | tracing::info!(actor_id = %viewer.0.id, runner_id = %id, disabled, "ci runner state changed"); |
| 304 | Ok(back_to_list(if disabled { "disabled" } else { "enabled" })) |
| 305 | } |
| 306 | |
| 307 | |
| 308 | #[route(POST "/admin/runners/{runner_id}/delete")] |
| 309 | async fn runner_delete(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> { |
| 310 | let viewer = admin_only(cx).await?; |
| 311 | if !check_csrf(&viewer.1, &form.csrf) { |
| 312 | return Err(forbidden().into()); |
| 313 | } |
| 314 | let id = path_param::<RunnerId>(cx); |
| 315 | let state = app_context::<AppState>(cx); |
| 316 | let Some(runner) = runner_named(state, id).await? else { |
| 317 | return Ok(back_to_list("missing")); |
| 318 | }; |
| 319 | state.store.delete_runner(id).await?; |
| 320 | audit::record( |
| 321 | &state.store, |
| 322 | Some(cx), |
| 323 | Actor::User(&viewer.0), |
| 324 | AuditAction::CiRunnerDeleted, |
| 325 | Target { |
| 326 | kind: "ci_runner", |
| 327 | id: &runner.id, |
| 328 | name: &runner.name, |
| 329 | }, |
| 330 | serde_json::json!({ "runner_id": runner.id }), |
| 331 | ) |
| 332 | .await; |
| 333 | Ok(back_to_list("deleted")) |
| 334 | } |
| 335 | |
| 336 | #[cfg(test)] |
| 337 | mod tests { |
| 338 | use super::*; |
| 339 | use forge_core::Store; |
| 340 | use topcoat::router::request::Request; |
| 341 | use topcoat::router::{Body, Router, to_bytes}; |
| 342 | |
| 343 | use crate::auth::SESSION_COOKIE; |
| 344 | use crate::test_support::{app_router, create_session, test_state}; |
| 345 | |
| 346 | const ADMIN_TOKEN: &str = "runners-admin-session"; |
| 347 | const USER_TOKEN: &str = "runners-user-session"; |
| 348 | const CSRF: &str = "runners-csrf"; |
| 349 | |
| 350 | async fn fixture(dir: &std::path::Path) -> (Store, Router) { |
| 351 | let state = test_state(dir).await; |
| 352 | let store = state.store.clone(); |
| 353 | let admin = store |
| 354 | .create_user("root", None, "h", true) |
| 355 | .await |
| 356 | .expect("admin"); |
| 357 | let user = store |
| 358 | .create_user("plain", None, "h", false) |
| 359 | .await |
| 360 | .expect("user"); |
| 361 | create_session(&store, &admin.id, ADMIN_TOKEN, CSRF).await; |
| 362 | create_session(&store, &user.id, USER_TOKEN, CSRF).await; |
| 363 | (store, app_router(state)) |
| 364 | } |
| 365 | |
| 366 | async fn send( |
| 367 | router: &Router, |
| 368 | method: &str, |
| 369 | uri: &str, |
| 370 | token: Option<&str>, |
| 371 | form: Option<String>, |
| 372 | ) -> (u16, String) { |
| 373 | let mut builder = Request::builder().method(method).uri(uri); |
| 374 | if let Some(token) = token { |
| 375 | builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}")); |
| 376 | } |
| 377 | let body = match form { |
| 378 | Some(form) => { |
| 379 | builder = builder.header("content-type", "application/x-www-form-urlencoded"); |
| 380 | Body::from(form) |
| 381 | } |
| 382 | None => Body::empty(), |
| 383 | }; |
| 384 | let res = router.handle(builder.body(body).expect("request")).await; |
| 385 | let status = res.status().as_u16(); |
| 386 | let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); |
| 387 | (status, String::from_utf8_lossy(&bytes).into_owned()) |
| 388 | } |
| 389 | |
| 390 | fn csrf_form(extra: &str) -> Option<String> { |
| 391 | Some(format!("csrf={CSRF}{extra}")) |
| 392 | } |
| 393 | |
| 394 | #[test] |
| 395 | fn register_command_uses_the_base_url() { |
| 396 | let cmd = register_command("https://forge.example/", "tok123"); |
| 397 | assert_eq!( |
| 398 | cmd, |
| 399 | "forge runner register --url https://forge.example --token tok123 --name <name> --labels linux" |
| 400 | ); |
| 401 | } |
| 402 | |
| 403 | #[tokio::test] |
| 404 | async fn page_is_404_for_anonymous_and_non_admins() { |
| 405 | let dir = tempfile::tempdir().expect("tempdir"); |
| 406 | let (_, router) = fixture(dir.path()).await; |
| 407 | let (anon, _) = send(&router, "GET", "/admin/runners", None, None).await; |
| 408 | assert_eq!(anon, 404, "anonymous gets 404 here, not a login redirect"); |
| 409 | let (user, _) = send(&router, "GET", "/admin/runners", Some(USER_TOKEN), None).await; |
| 410 | assert_eq!(user, 404); |
| 411 | let (tok, _) = send( |
| 412 | &router, |
| 413 | "POST", |
| 414 | "/admin/runners/tokens", |
| 415 | Some(USER_TOKEN), |
| 416 | csrf_form(""), |
| 417 | ) |
| 418 | .await; |
| 419 | assert_eq!(tok, 404); |
| 420 | let (del, _) = send( |
| 421 | &router, |
| 422 | "POST", |
| 423 | "/admin/runners/x/delete", |
| 424 | Some(USER_TOKEN), |
| 425 | csrf_form(""), |
| 426 | ) |
| 427 | .await; |
| 428 | assert_eq!(del, 404); |
| 429 | } |
| 430 | |
| 431 | #[tokio::test] |
| 432 | async fn posts_without_a_valid_csrf_token_are_forbidden() { |
| 433 | let dir = tempfile::tempdir().expect("tempdir"); |
| 434 | let (store, router) = fixture(dir.path()).await; |
| 435 | let bad = Some("csrf=wrong".to_owned()); |
| 436 | let (tok, _) = send( |
| 437 | &router, |
| 438 | "POST", |
| 439 | "/admin/runners/tokens", |
| 440 | Some(ADMIN_TOKEN), |
| 441 | bad.clone(), |
| 442 | ) |
| 443 | .await; |
| 444 | assert_eq!(tok, 403); |
| 445 | let (del, _) = send( |
| 446 | &router, |
| 447 | "POST", |
| 448 | "/admin/runners/x/delete", |
| 449 | Some(ADMIN_TOKEN), |
| 450 | bad.clone(), |
| 451 | ) |
| 452 | .await; |
| 453 | assert_eq!(del, 403); |
| 454 | let (dis, _) = send( |
| 455 | &router, |
| 456 | "POST", |
| 457 | "/admin/runners/x/disabled", |
| 458 | Some(ADMIN_TOKEN), |
| 459 | Some("csrf=wrong&disabled=1".to_owned()), |
| 460 | ) |
| 461 | .await; |
| 462 | assert_eq!(dis, 403); |
| 463 | assert!(store.list_runners().await.expect("list").is_empty()); |
| 464 | } |
| 465 | |
| 466 | #[tokio::test] |
| 467 | async fn token_page_shows_the_token_once_and_the_list_never_does() { |
| 468 | let dir = tempfile::tempdir().expect("tempdir"); |
| 469 | let (store, router) = fixture(dir.path()).await; |
| 470 | let (status, created) = send( |
| 471 | &router, |
| 472 | "POST", |
| 473 | "/admin/runners/tokens", |
| 474 | Some(ADMIN_TOKEN), |
| 475 | csrf_form(""), |
| 476 | ) |
| 477 | .await; |
| 478 | assert_eq!(status, 200); |
| 479 | let token = created |
| 480 | .split("--token ") |
| 481 | .nth(1) |
| 482 | .and_then(|rest| rest.split(' ').next()) |
| 483 | .expect("command with token") |
| 484 | .to_owned(); |
| 485 | assert!(created.contains("forge runner register --url")); |
| 486 | assert!( |
| 487 | created.contains("--name <name> --labels linux"), |
| 488 | "placeholder is escaped" |
| 489 | ); |
| 490 | assert_eq!( |
| 491 | created.matches(&token).count(), |
| 492 | 2, |
| 493 | "token in the box and the command" |
| 494 | ); |
| 495 | |
| 496 | let (_, list) = send(&router, "GET", "/admin/runners", Some(ADMIN_TOKEN), None).await; |
| 497 | assert!(!list.contains(&token), "the list never shows the token"); |
| 498 | assert!(!list.contains("forge runner register")); |
| 499 | |
| 500 | assert!(store.list_runners().await.expect("list").is_empty()); |
| 501 | } |
| 502 | |
| 503 | #[tokio::test] |
| 504 | async fn disable_enable_and_delete_a_runner() { |
| 505 | let dir = tempfile::tempdir().expect("tempdir"); |
| 506 | let (store, router) = fixture(dir.path()).await; |
| 507 | let (_, created) = send( |
| 508 | &router, |
| 509 | "POST", |
| 510 | "/admin/runners/tokens", |
| 511 | Some(ADMIN_TOKEN), |
| 512 | csrf_form(""), |
| 513 | ) |
| 514 | .await; |
| 515 | let token = created |
| 516 | .split("--token ") |
| 517 | .nth(1) |
| 518 | .and_then(|rest| rest.split(' ').next()) |
| 519 | .expect("token") |
| 520 | .to_owned(); |
| 521 | store |
| 522 | .register_runner(&token, "box-1", &["linux".to_owned()]) |
| 523 | .await |
| 524 | .expect("register"); |
| 525 | let runner = store.list_runners().await.expect("list").remove(0); |
| 526 | let disable = format!("/admin/runners/{}/disabled", runner.id); |
| 527 | let (status, _) = send( |
| 528 | &router, |
| 529 | "POST", |
| 530 | &disable, |
| 531 | Some(ADMIN_TOKEN), |
| 532 | csrf_form("&disabled=1"), |
| 533 | ) |
| 534 | .await; |
| 535 | assert_eq!(status, 303); |
| 536 | assert!(store.list_runners().await.expect("list")[0].disabled); |
| 537 | |
| 538 | let (_, _) = send( |
| 539 | &router, |
| 540 | "POST", |
| 541 | &disable, |
| 542 | Some(ADMIN_TOKEN), |
| 543 | csrf_form("&disabled=0"), |
| 544 | ) |
| 545 | .await; |
| 546 | assert!(!store.list_runners().await.expect("list")[0].disabled); |
| 547 | |
| 548 | let delete = format!("/admin/runners/{}/delete", runner.id); |
| 549 | let (status, _) = send(&router, "POST", &delete, Some(ADMIN_TOKEN), csrf_form("")).await; |
| 550 | assert_eq!(status, 303); |
| 551 | assert!(store.list_runners().await.expect("list").is_empty()); |
| 552 | |
| 553 | let (_, _) = send(&router, "POST", &delete, Some(ADMIN_TOKEN), csrf_form("")).await; |
| 554 | let (_, page) = send( |
| 555 | &router, |
| 556 | "GET", |
| 557 | "/admin/runners?notice=missing", |
| 558 | Some(ADMIN_TOKEN), |
| 559 | None, |
| 560 | ) |
| 561 | .await; |
| 562 | assert!(page.contains("No runner has that id")); |
| 563 | } |
| 564 | } |