//! Route tests for the CI run and job pages. The fixture: alice owns the private repo `proj`, bob //! has Write, dave has Read, and carol is outside the repo. use std::path::{Path, PathBuf}; use forge_core::ci::{CiJob, CiRun, JobState, NewJob, RunState}; use forge_core::{Role, Store, Visibility}; use forge_git::repo::{init_bare, repo_path}; use topcoat::router::request::Request; use topcoat::router::{Body, HeaderMap, Router, to_bytes}; use crate::auth::SESSION_COOKIE; use crate::blobs::BlobStore; use crate::ci::{artifact_key, log_path}; use crate::test_support::{app_router, create_session, test_state}; const CSRF: &str = "ci-test-csrf"; const SHA: &str = "0123456789abcdef0123456789abcdef01234567"; const SPEC: &str = r#"{"runs_on":[]}"#; const RUN_BASE: &str = "/alice/proj/ci"; struct Fixture { store: Store, app: Router, repo: forge_core::Repo, alice: String, data_dir: PathBuf, blobs: BlobStore, } async fn fixture(dir: &Path) -> Fixture { let state = test_state(dir).await; let store = state.store.clone(); let alice = store .create_user("alice", None, "unused", false) .await .expect("alice"); let bob = store .create_user("bob", None, "unused", false) .await .expect("bob"); let dave = store .create_user("dave", None, "unused", false) .await .expect("dave"); let carol = store .create_user("carol", None, "unused", false) .await .expect("carol"); let repo = store .create_repo(&alice.id, "proj", "", Visibility::Private, "main") .await .expect("repo"); let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); init_bare(&state.git, &bare, "main") .await .expect("init bare"); store .set_collaborator(&repo.id, &bob.id, Role::Write) .await .expect("bob writes"); store .set_collaborator(&repo.id, &dave.id, Role::Read) .await .expect("dave reads"); create_session(&store, &alice.id, "alice-token", CSRF).await; create_session(&store, &bob.id, "bob-token", CSRF).await; create_session(&store, &dave.id, "dave-token", CSRF).await; create_session(&store, &carol.id, "carol-token", CSRF).await; let data_dir = state.config.data_dir.clone(); let blobs = state.blobs.clone(); Fixture { store, app: app_router(state), repo, alice: alice.id, data_dir, blobs, } } fn cookie_for(token: &str) -> String { format!("__Host-{SESSION_COOKIE}={token}") } /// A response: status, headers and the whole body. struct Reply { status: u16, headers: HeaderMap, body: Vec, } impl Reply { fn text(&self) -> String { String::from_utf8_lossy(&self.body).into_owned() } fn location(&self) -> Option { self.headers .get("location") .and_then(|value| value.to_str().ok()) .map(str::to_owned) } } async fn get(app: &Router, uri: &str, cookie: Option<&str>) -> Reply { let mut builder = Request::builder().method("GET").uri(uri); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } let req = builder.body(Body::from(String::new())).expect("request"); collect(app.handle(req).await).await } /// A POST with the CSRF token and the given form fields. async fn post(app: &Router, uri: &str, cookie: &str, fields: &[(&str, &str)]) -> Reply { let mut form = form_urlencoded::Serializer::new(String::new()); form.append_pair("csrf", CSRF); for (name, value) in fields { form.append_pair(name, value); } let req = Request::builder() .method("POST") .uri(uri) .header("cookie", cookie) .header("content-type", "application/x-www-form-urlencoded") .body(Body::from(form.finish())) .expect("request"); collect(app.handle(req).await).await } async fn collect(res: topcoat::router::response::Response) -> Reply { let status = res.status().as_u16(); let headers = res.headers().clone(); let body = to_bytes(res.into_body(), usize::MAX).await.expect("body"); Reply { status, headers, body: body.to_vec(), } } /// Creates a run on `main` with one job per name. Jobs wait for approval when `needs_approval`. async fn run_with(fx: &Fixture, jobs: &[&str], needs_approval: bool) -> CiRun { let needs: &[String] = &[]; let specs: Vec> = jobs .iter() .map(|name| NewJob { name, spec_json: SPEC, needs, }) .collect(); fx.store .create_run( &fx.repo.id, "push", "refs/heads/main", SHA, None, &fx.alice, needs_approval, &specs, None, ) .await .expect("run") } /// Claims the one queued job with a fresh runner and finishes it as a success. async fn finish_job(fx: &Fixture) -> CiJob { let token = fx .store .create_registration_token(&fx.alice, 24) .await .expect("token"); let (runner, _) = fx .store .register_runner(&token, "runner-1", &["linux".to_owned()]) .await .expect("runner"); let (job, _, _) = fx .store .claim_job(&runner) .await .expect("claim") .expect("a queued job"); fx.store .finish_job(&job.id, JobState::Success, Some(0), None) .await .expect("finish"); job } async fn job_of(fx: &Fixture, run: &CiRun) -> CiJob { fx.store .jobs_for_run(&run.id) .await .expect("jobs") .into_iter() .next() .expect("a job") } #[tokio::test] async fn private_repo_is_not_found_to_outsiders_and_anonymous() { let dir = tempfile::tempdir().expect("tempdir"); let fx = fixture(dir.path()).await; let run = run_with(&fx, &["build"], false).await; let job = job_of(&fx, &run).await; let urls = [ RUN_BASE.to_owned(), format!("{RUN_BASE}/runs/{}", run.number), format!("/alice/proj/ci/jobs/{}", job.id), format!("/alice/proj/ci/jobs/{}/log.txt", job.id), format!("/alice/proj/ci/jobs/{}/artifacts/x.bin", job.id), ]; for url in &urls { let anon = get(&fx.app, url, None).await; assert_eq!(anon.status, 404, "anonymous {url}"); let outsider = get(&fx.app, url, Some(&cookie_for("carol-token"))).await; assert_eq!(outsider.status, 404, "outsider {url}"); } let reader = get(&fx.app, RUN_BASE, Some(&cookie_for("dave-token"))).await; assert_eq!(reader.status, 200); } #[tokio::test] async fn buttons_and_manual_form_need_write() { let dir = tempfile::tempdir().expect("tempdir"); let fx = fixture(dir.path()).await; let run = run_with(&fx, &["build"], false).await; let run_url = format!("{RUN_BASE}/runs/{}", run.number); let writer = get(&fx.app, &run_url, Some(&cookie_for("bob-token"))).await; assert!(writer.text().contains(">Re-run<"), "writer sees re-run"); assert!(writer.text().contains(">Cancel<"), "writer sees cancel"); let reader = get(&fx.app, &run_url, Some(&cookie_for("dave-token"))).await; assert_eq!(reader.status, 200); assert!(!reader.text().contains(">Re-run<"), "reader sees no re-run"); assert!(!reader.text().contains(">Cancel<"), "reader sees no cancel"); let list = get(&fx.app, RUN_BASE, Some(&cookie_for("dave-token"))).await; assert!( !list.text().contains("Run workflow"), "reader has no manual form" ); let list = get(&fx.app, RUN_BASE, Some(&cookie_for("bob-token"))).await; assert!( list.text().contains("Run workflow"), "writer has the manual form" ); let refused = post( &fx.app, &format!("{run_url}/cancel"), &cookie_for("dave-token"), &[], ) .await; assert_eq!(refused.status, 403, "reader cannot cancel"); let still = fx .store .run_by_id(&run.id) .await .expect("run") .expect("run"); assert_eq!(still.state, RunState::Queued); } #[tokio::test] async fn run_list_pages_25_at_a_time() { let dir = tempfile::tempdir().expect("tempdir"); let fx = fixture(dir.path()).await; for _ in 0..30 { run_with(&fx, &[], false).await; } let first = get(&fx.app, RUN_BASE, Some(&cookie_for("alice-token"))).await; let text = first.text(); assert_eq!(text.matches("href=\"/alice/proj/ci/runs/").count(), 25); assert!(text.contains("?page=2"), "older link"); assert!(!text.contains(">Newer<")); let second = get( &fx.app, &format!("{RUN_BASE}?page=2"), Some(&cookie_for("alice-token")), ) .await; let text = second.text(); assert_eq!(text.matches("href=\"/alice/proj/ci/runs/").count(), 5); assert!(text.contains(">Newer<"), "newer link"); assert!(!text.contains("?page=3"), "no older link on the last page"); let bogus = get( &fx.app, &format!("{RUN_BASE}?page=abc"), Some(&cookie_for("alice-token")), ) .await; assert_eq!(bogus.status, 200); assert_eq!( bogus.text().matches("href=\"/alice/proj/ci/runs/").count(), 25 ); } #[tokio::test] async fn meta_refresh_only_while_the_job_is_not_terminal() { let dir = tempfile::tempdir().expect("tempdir"); let fx = fixture(dir.path()).await; let run = run_with(&fx, &["build"], false).await; let job = job_of(&fx, &run).await; let url = format!("/alice/proj/ci/jobs/{}", job.id); let queued = get(&fx.app, &url, Some(&cookie_for("alice-token"))).await; assert!( queued.text().contains("http-equiv=\"refresh\""), "refresh while queued" ); finish_job(&fx).await; let done = get(&fx.app, &url, Some(&cookie_for("alice-token"))).await; assert!( !done.text().contains("http-equiv=\"refresh\""), "no refresh when finished" ); } #[tokio::test] async fn job_page_tails_the_log_at_512_kib() { let dir = tempfile::tempdir().expect("tempdir"); let fx = fixture(dir.path()).await; let run = run_with(&fx, &["build"], false).await; let job = job_of(&fx, &run).await; let path = log_path(&fx.data_dir, &job.id); std::fs::create_dir_all(path.parent().expect("log dir")).expect("log dir"); let mut body = b"HEAD-MARK\n".to_vec(); let line = format!("{}\n", "x".repeat(99)); for _ in 0..6_000 { body.extend_from_slice(line.as_bytes()); } body.extend_from_slice(b"TAIL-MARK\n"); std::fs::write(&path, &body).expect("log"); let page = get( &fx.app, &format!("/alice/proj/ci/jobs/{}", job.id), Some(&cookie_for("alice-token")), ) .await; let text = page.text(); assert!(text.contains("TAIL-MARK")); assert!(!text.contains("HEAD-MARK")); assert!(text.contains("Showing the last 512 KiB")); assert!( page.body.len() < 530 * 1024, "page is {} bytes", page.body.len() ); } #[tokio::test] async fn log_txt_is_the_full_file_for_a_finished_job() { let dir = tempfile::tempdir().expect("tempdir"); let fx = fixture(dir.path()).await; let run = run_with(&fx, &["build"], false).await; let job = job_of(&fx, &run).await; let path = log_path(&fx.data_dir, &job.id); std::fs::create_dir_all(path.parent().expect("log dir")).expect("log dir"); std::fs::write(&path, b"line one\nline two\n").expect("log"); finish_job(&fx).await; let res = get( &fx.app, &format!("/alice/proj/ci/jobs/{}/log.txt", job.id), Some(&cookie_for("alice-token")), ) .await; assert_eq!(res.status, 200); assert_eq!(res.body, b"line one\nline two\n"); assert_eq!( res.headers .get("content-type") .and_then(|v| v.to_str().ok()), Some("text/plain; charset=utf-8") ); assert_eq!( res.headers .get("x-content-type-options") .and_then(|v| v.to_str().ok()), Some("nosniff") ); } #[tokio::test] async fn artifact_download_sends_attachment_headers() { let dir = tempfile::tempdir().expect("tempdir"); let fx = fixture(dir.path()).await; let run = run_with(&fx, &["build"], false).await; let job = job_of(&fx, &run).await; let payload = b"artifact bytes"; let artifact = fx .store .add_artifact(&job.id, "report.txt", payload.len() as i64, SHA) .await .expect("artifact row"); let mut writer = fx .blobs .begin(&artifact_key(&artifact.id), 1024) .await .expect("begin"); writer.push(payload).await.expect("push"); writer.finish().await.expect("finish"); let res = get( &fx.app, &format!("/alice/proj/ci/jobs/{}/artifacts/report.txt", job.id), Some(&cookie_for("alice-token")), ) .await; assert_eq!(res.status, 200); assert_eq!(res.body, payload); let header = |name: &str| { res.headers .get(name) .and_then(|v| v.to_str().ok()) .map(str::to_owned) }; assert_eq!( header("content-disposition").as_deref(), Some("attachment; filename=\"report.txt\"") ); assert_eq!(header("content-length").as_deref(), Some("14")); assert_eq!(header("x-content-type-options").as_deref(), Some("nosniff")); assert_eq!( header("content-type").as_deref(), Some("application/octet-stream") ); } #[tokio::test] async fn approve_only_works_while_waiting_for_approval() { let dir = tempfile::tempdir().expect("tempdir"); let fx = fixture(dir.path()).await; let run = run_with(&fx, &["build"], true).await; let run_url = format!("{RUN_BASE}/runs/{}", run.number); let waiting = get(&fx.app, &run_url, Some(&cookie_for("bob-token"))).await; assert!( waiting.text().contains(">Approve and run<"), "approve button while waiting" ); let reader = get(&fx.app, &run_url, Some(&cookie_for("dave-token"))).await; assert!( !reader.text().contains(">Approve and run<"), "no approve button for readers" ); let approved = post( &fx.app, &format!("{run_url}/approve"), &cookie_for("bob-token"), &[], ) .await; assert!( approved .location() .unwrap_or_default() .ends_with("?notice=approved") ); let now = fx .store .run_by_id(&run.id) .await .expect("run") .expect("run"); assert_eq!(now.state, RunState::Queued); let page = get(&fx.app, &run_url, Some(&cookie_for("bob-token"))).await; assert!( !page.text().contains(">Approve and run<"), "no approve button after approval" ); let again = post( &fx.app, &format!("{run_url}/approve"), &cookie_for("bob-token"), &[], ) .await; assert!( again .location() .unwrap_or_default() .ends_with("?notice=not-waiting") ); let still = fx .store .run_by_id(&run.id) .await .expect("run") .expect("run"); assert_eq!(still.state, RunState::Queued); } #[tokio::test] async fn cancel_and_rerun_post_and_redirect() { let dir = tempfile::tempdir().expect("tempdir"); let fx = fixture(dir.path()).await; let run = run_with(&fx, &["build"], false).await; let run_url = format!("{RUN_BASE}/runs/{}", run.number); let cancelled = post( &fx.app, &format!("{run_url}/cancel"), &cookie_for("bob-token"), &[], ) .await; assert!( cancelled .location() .unwrap_or_default() .ends_with("?notice=cancelled") ); let now = fx .store .run_by_id(&run.id) .await .expect("run") .expect("run"); assert_eq!(now.state, RunState::Cancelled); let finished = post( &fx.app, &format!("{run_url}/cancel"), &cookie_for("bob-token"), &[], ) .await; assert!( finished .location() .unwrap_or_default() .ends_with("?notice=finished") ); let rerun = post( &fx.app, &format!("{run_url}/rerun"), &cookie_for("bob-token"), &[], ) .await; assert_eq!( rerun.location().as_deref(), Some("/alice/proj/ci/runs/2?notice=rerun") ); assert!( fx.store .run_by_number(&fx.repo.id, 2) .await .expect("lookup") .is_some() ); } #[tokio::test] async fn manual_run_refuses_an_unknown_branch() { let dir = tempfile::tempdir().expect("tempdir"); let fx = fixture(dir.path()).await; let res = post( &fx.app, RUN_BASE, &cookie_for("bob-token"), &[("branch", "no-such-branch")], ) .await; assert!( res.location() .unwrap_or_default() .ends_with("?notice=no-branch") ); let reader = post( &fx.app, RUN_BASE, &cookie_for("dave-token"), &[("branch", "main")], ) .await; assert_eq!(reader.status, 403, "reader cannot start a manual run"); }