//! Route tests for the runner and job API. Each test drives the public router, the same one the //! server builds, so routing, auth and status codes are covered end to end. use std::path::PathBuf; use std::sync::Arc; use forge_core::ci::{CiJob, CiRun, CiRunner, JobState, NewJob}; use forge_core::{Repo, Store, Visibility}; use http::header::{AUTHORIZATION, CONTENT_TYPE}; use serde_json::{Value, json}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use super::{artifact_key, fit_to_cap, log_path, valid_artifact_name}; use crate::pages::commits::test_support::{Fixture, OWNER, REPO}; use crate::test_support::{app_router, test_state}; /// A response: status, headers and raw body. struct Sent { status: u16, headers: http::HeaderMap, body: Vec, } impl Sent { fn json(&self) -> Value { serde_json::from_slice(&self.body).unwrap_or(Value::Null) } } /// Sends one request through the router. async fn send( router: &Router, method: &str, uri: &str, headers: &[(&str, String)], body: Vec, ) -> Sent { let mut builder = Request::builder().method(method).uri(uri); for (name, value) in headers { builder = builder.header(*name, value.as_str()); } let req = builder.body(Body::from(body)).unwrap(); let res = router.handle(req).await; let status = res.status().as_u16(); let headers = res.headers().clone(); let body = to_bytes(res.into_body(), usize::MAX) .await .unwrap() .to_vec(); Sent { status, headers, body, } } fn bearer(token: &str) -> (&'static str, String) { (AUTHORIZATION.as_str(), format!("Bearer {token}")) } fn json_body(value: &Value) -> Vec { value.to_string().into_bytes() } /// A repo with an owner, and a router over a fresh store. `tune` adjusts the CI limits. struct Bare { router: Router, store: Store, data_dir: PathBuf, owner_id: String, repo: Repo, _dir: tempfile::TempDir, } async fn bare(tune: impl FnOnce(&mut forge_core::config::CiConfig)) -> Bare { let dir = tempfile::tempdir().expect("tempdir"); let data_dir = dir.path().to_path_buf(); let mut state = test_state(&data_dir).await; let mut config = (*state.config).clone(); tune(&mut config.ci); state.config = Arc::new(config); let store = state.store.clone(); let router = app_router(state); let owner = store .create_user(OWNER, None, "not-a-real-hash", false) .await .unwrap(); let repo = store .create_repo(&owner.id, REPO, "", Visibility::Public, "main") .await .unwrap(); Bare { router, store, data_dir, owner_id: owner.id, repo, _dir: dir, } } /// A runner registered through the store, with its secret. async fn runner(env: &Bare, labels: &[&str]) -> (CiRunner, String) { let token = env .store .create_registration_token(&env.owner_id, 24) .await .unwrap(); let labels: Vec = labels.iter().map(|label| (*label).to_owned()).collect(); env.store .register_runner(&token, "runner", &labels) .await .unwrap() } /// A JobSpec as JSON. `runs_on` and `secrets` are the only fields the tests vary. fn spec(runs_on: &[&str], secrets: &[&str]) -> String { json!({ "image": "alpine:3", "run": "echo hi", "needs": [], "secrets": secrets, "env": {}, "artifacts": [], "runs_on": runs_on, "timeout_minutes": 60, "network": true, }) .to_string() } /// Creates a run. `jobs` are (name, spec, needs). async fn make_run( env: &Bare, trigger: &str, pull: Option, commit: &str, jobs: &[(&str, String, Vec)], ) -> CiRun { let new: Vec = jobs .iter() .map(|(name, spec_json, needs)| NewJob { name, spec_json, needs, }) .collect(); env.store .create_run( &env.repo.id, trigger, "refs/heads/main", commit, pull, &env.owner_id, false, &new, None, ) .await .unwrap() } fn job(name: &str, spec_json: String) -> (&str, String, Vec) { (name, spec_json, Vec::new()) } const COMMIT: &str = "1111111111111111111111111111111111111111"; async fn claim(router: &Router, secret: &str) -> Sent { send( router, "POST", "/api/v1/ci/runners/claim", &[bearer(secret)], b"{}".to_vec(), ) .await } /// Claims a job for `secret` and returns the assignment JSON. async fn claim_job(router: &Router, secret: &str) -> Value { let sent = claim(router, secret).await; assert_eq!(sent.status, 200, "{}", sent.json()); sent.json() } fn log_post( job_id: &str, token: &str, offset: u64, body: &[u8], ) -> (String, Vec<(&'static str, String)>, Vec) { ( format!("/api/v1/ci/jobs/{job_id}/logs"), vec![bearer(token), ("x-forge-log-offset", offset.to_string())], body.to_vec(), ) } // --------------------------------------------------------------------------------------------- // Registration // --------------------------------------------------------------------------------------------- #[tokio::test] async fn register_good_and_reused_tokens() { let env = bare(|_| {}).await; let uri = "/api/v1/ci/runners/register"; let good = env .store .create_registration_token(&env.owner_id, 24) .await .unwrap(); let body = json!({ "token": good, "name": "linux-1", "labels": ["linux"] }); let sent = send(&env.router, "POST", uri, &[], json_body(&body)).await; assert_eq!(sent.status, 201, "{}", sent.json()); assert!( sent.json()["runner_secret"] .as_str() .is_some_and(|s| !s.is_empty()) ); assert!(sent.json()["runner_id"].as_str().is_some()); let reused = send(&env.router, "POST", uri, &[], json_body(&body)).await; assert_eq!(reused.status, 401, "{}", reused.json()); } #[tokio::test] async fn register_rejects_bad_names() { let env = bare(|_| {}).await; let body = json!({ "token": "unused", "name": " ", "labels": [] }); let sent = send( &env.router, "POST", "/api/v1/ci/runners/register", &[], json_body(&body), ) .await; assert_eq!(sent.status, 422, "{}", sent.json()); } // --------------------------------------------------------------------------------------------- // Claims // --------------------------------------------------------------------------------------------- #[tokio::test] async fn claim_is_204_without_work_and_200_with_work() { let env = bare(|_| {}).await; let (_, secret) = runner(&env, &["linux"]).await; let sent = claim(&env.router, &secret).await; assert_eq!(sent.status, 204); make_run(&env, "push", None, COMMIT, &[job("build", spec(&[], &[]))]).await; let assignment = claim_job(&env.router, &secret).await; assert_eq!(assignment["job_name"], "build"); assert_eq!(assignment["repo"], format!("{OWNER}/{REPO}")); assert_eq!(assignment["commit"], COMMIT); assert_eq!(assignment["image"], "alpine:3"); assert!( assignment["job_token"] .as_str() .is_some_and(|t| !t.is_empty()) ); assert!( assignment["clone_url"] .as_str() .is_some_and(|u| u.ends_with(&format!("/{OWNER}/{REPO}.git"))) ); } #[tokio::test] async fn claim_skips_jobs_with_a_label_the_runner_lacks() { let env = bare(|_| {}).await; let (_, secret) = runner(&env, &["linux"]).await; make_run( &env, "push", None, COMMIT, &[job("gpu", spec(&["gpu"], &[]))], ) .await; let sent = claim(&env.router, &secret).await; assert_eq!(sent.status, 204); } #[tokio::test] async fn disabled_runner_gets_403() { let env = bare(|_| {}).await; let (runner, secret) = runner(&env, &["linux"]).await; env.store .set_runner_disabled(&runner.id, true) .await .unwrap(); let sent = claim(&env.router, &secret).await; assert_eq!(sent.status, 403, "{}", sent.json()); } #[tokio::test] async fn unknown_runner_secret_is_401() { let env = bare(|_| {}).await; let sent = claim(&env.router, "not-a-secret").await; assert_eq!(sent.status, 401); } // --------------------------------------------------------------------------------------------- // Secrets on fork-PR runs // --------------------------------------------------------------------------------------------- #[tokio::test] async fn fork_pr_runs_get_no_secrets() { let env = bare(|_| {}).await; let (_, secret) = runner(&env, &[]).await; env.store .set_ci_secret(&env.repo.id, "DEPLOY", b"s3cret-value") .await .unwrap(); make_run( &env, "push", None, COMMIT, &[job("deploy", spec(&[], &["DEPLOY"]))], ) .await; let trusted = claim_job(&env.router, &secret).await; assert_eq!(trusted["secrets"]["DEPLOY"], "s3cret-value"); // A pull request with no pull row behaves as a fork: nothing is handed out. make_run( &env, "pull_request", None, COMMIT, &[job("deploy", spec(&[], &["DEPLOY"]))], ) .await; let fork = claim_job(&env.router, &secret).await; assert_eq!(fork["secrets"], json!({})); } // --------------------------------------------------------------------------------------------- // Logs // --------------------------------------------------------------------------------------------- #[tokio::test] async fn logs_append_retry_mismatch_and_mask() { let env = bare(|_| {}).await; let (_, secret) = runner(&env, &[]).await; env.store .set_ci_secret(&env.repo.id, "TOKEN", b"hunter2-token") .await .unwrap(); make_run( &env, "push", None, COMMIT, &[job("test", spec(&[], &["TOKEN"]))], ) .await; let assignment = claim_job(&env.router, &secret).await; let job_id = assignment["job_id"].as_str().unwrap().to_owned(); let token = assignment["job_token"].as_str().unwrap().to_owned(); let path = log_path(&env.data_dir, &job_id); let first = b"hello hunter2-token world".to_vec(); let (uri, headers, body) = log_post(&job_id, &token, 0, &first); let sent = send(&env.router, "POST", &uri, &headers, body).await; assert_eq!(sent.status, 200, "{}", sent.json()); let stored = std::fs::read_to_string(&path).unwrap(); assert_eq!(stored, "hello *** world"); // A retry of the same chunk at the same offset is accepted and writes nothing. let (uri, headers, body) = log_post(&job_id, &token, 0, &first); let sent = send(&env.router, "POST", &uri, &headers, body).await; assert_eq!(sent.status, 200); assert_eq!(std::fs::read_to_string(&path).unwrap(), stored); // A different chunk at an old offset is a mismatch and says where the log really ends. let (uri, headers, body) = log_post(&job_id, &token, 0, b"x"); let sent = send(&env.router, "POST", &uri, &headers, body).await; assert_eq!(sent.status, 409); assert_eq!( sent.headers .get("x-forge-log-size") .and_then(|v| v.to_str().ok()), Some(first.len().to_string().as_str()) ); // The next chunk, at the end, is stored. let (uri, headers, body) = log_post(&job_id, &token, first.len() as u64, b"\nmore\n"); let sent = send(&env.router, "POST", &uri, &headers, body).await; assert_eq!(sent.status, 200); assert_eq!( std::fs::read_to_string(&path).unwrap(), "hello *** world\nmore\n" ); } #[tokio::test] async fn log_offset_header_is_required() { let env = bare(|_| {}).await; let (_, secret) = runner(&env, &[]).await; make_run(&env, "push", None, COMMIT, &[job("a", spec(&[], &[]))]).await; let assignment = claim_job(&env.router, &secret).await; let job_id = assignment["job_id"].as_str().unwrap(); let token = assignment["job_token"].as_str().unwrap(); let sent = send( &env.router, "POST", &format!("/api/v1/ci/jobs/{job_id}/logs"), &[bearer(token)], b"text".to_vec(), ) .await; assert_eq!(sent.status, 400); } #[test] fn log_cap_keeps_what_fits_then_one_marker() { // 100 byte cap. The marker is 17 bytes, so 83 bytes of data fit. let out = fit_to_cap(0, 100, &[b'a'; 150]); assert_eq!(out.len(), 100); assert!(out.ends_with(b"[log truncated]\n")); // Room is left: a short chunk is kept whole. assert_eq!(fit_to_cap(10, 100, b"hello"), b"hello"); // The cap is reached: nothing more, not even a second marker. assert!(fit_to_cap(100, 100, b"more").is_empty()); // Exactly full at the data limit: the next bytes bring the marker, and only that. assert_eq!(fit_to_cap(83, 100, b"x"), b"\n[log truncated]\n"); } // --------------------------------------------------------------------------------------------- // Heartbeat // --------------------------------------------------------------------------------------------- #[tokio::test] async fn heartbeat_reports_a_cancel_request() { let env = bare(|_| {}).await; let (_, secret) = runner(&env, &[]).await; let run = make_run(&env, "push", None, COMMIT, &[job("a", spec(&[], &[]))]).await; let assignment = claim_job(&env.router, &secret).await; let job_id = assignment["job_id"].as_str().unwrap(); let token = assignment["job_token"].as_str().unwrap(); let uri = format!("/api/v1/ci/jobs/{job_id}/heartbeat"); let sent = send(&env.router, "POST", &uri, &[bearer(token)], Vec::new()).await; assert_eq!(sent.status, 200); assert_eq!(sent.json()["cancel"], false); env.store.request_cancel(&run.id).await.unwrap(); let sent = send(&env.router, "POST", &uri, &[bearer(token)], Vec::new()).await; assert_eq!(sent.status, 200); assert_eq!(sent.json()["cancel"], true); let sent = send(&env.router, "POST", &uri, &[bearer("wrong")], Vec::new()).await; assert_eq!(sent.status, 401); } // --------------------------------------------------------------------------------------------- // Artifacts // --------------------------------------------------------------------------------------------- #[test] fn artifact_names_are_validated() { assert!(valid_artifact_name("out.tar.gz")); assert!(valid_artifact_name("a+b_c-1")); assert!(!valid_artifact_name("")); assert!(!valid_artifact_name(".hidden")); assert!(!valid_artifact_name("a b")); assert!(!valid_artifact_name("a/b")); assert!(!valid_artifact_name(&"a".repeat(129))); } async fn put_artifact( router: &Router, job_id: &str, token: &str, name: &str, body: Vec, ) -> Sent { let uri = format!("/api/v1/ci/jobs/{job_id}/artifacts/{name}"); send(router, "PUT", &uri, &[bearer(token)], body).await } #[tokio::test] async fn artifact_upload_stores_and_checks_name_count_and_size() { let env = bare(|ci| { ci.max_artifacts_per_job = 3; ci.max_artifact_mb = 1; }) .await; let (_, secret) = runner(&env, &[]).await; make_run(&env, "push", None, COMMIT, &[job("a", spec(&[], &[]))]).await; let assignment = claim_job(&env.router, &secret).await; let job_id = assignment["job_id"].as_str().unwrap().to_owned(); let token = assignment["job_token"].as_str().unwrap().to_owned(); let bad = put_artifact(&env.router, &job_id, &token, ".hidden", b"x".to_vec()).await; assert_eq!(bad.status, 422); let ok = put_artifact(&env.router, &job_id, &token, "out.txt", b"abc".to_vec()).await; assert_eq!(ok.status, 201, "{}", ok.json()); let stored = env .store .artifact(&job_id, "out.txt") .await .unwrap() .expect("artifact row"); assert_eq!(stored.size, 3); assert_eq!( stored.sha256, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" ); assert_eq!(artifact_key(&stored.id), format!("ci/{}", stored.id)); let again = put_artifact(&env.router, &job_id, &token, "out.txt", b"abc".to_vec()).await; assert_eq!(again.status, 409); let second = put_artifact(&env.router, &job_id, &token, "two.txt", b"two".to_vec()).await; assert_eq!(second.status, 201); let too_big = put_artifact( &env.router, &job_id, &token, "big.bin", vec![0u8; 1024 * 1024 + 1], ) .await; assert_eq!(too_big.status, 413); let third = put_artifact(&env.router, &job_id, &token, "three.txt", b"three".to_vec()).await; assert_eq!(third.status, 201); let fourth = put_artifact(&env.router, &job_id, &token, "four.txt", b"four".to_vec()).await; assert_eq!(fourth.status, 409, "the count cap is 3"); } // --------------------------------------------------------------------------------------------- // Finish // --------------------------------------------------------------------------------------------- fn finish_body(outcome: &str) -> Vec { json_body(&json!({ "outcome": outcome, "exit_code": 0, "message": null })) } async fn two_step_run(env: &Bare) -> CiRun { make_run( env, "push", None, COMMIT, &[ job("a", spec(&[], &[])), ( "b", spec(&[], &[]).replace("\"needs\":[]", "\"needs\":[\"a\"]"), vec!["a".to_owned()], ), ], ) .await } #[tokio::test] async fn finish_queues_dependants_on_success() { let env = bare(|_| {}).await; let (_, secret) = runner(&env, &[]).await; let run = two_step_run(&env).await; let assignment = claim_job(&env.router, &secret).await; assert_eq!(assignment["job_name"], "a"); let job_id = assignment["job_id"].as_str().unwrap(); let token = assignment["job_token"].as_str().unwrap(); let sent = send( &env.router, "POST", &format!("/api/v1/ci/jobs/{job_id}/finish"), &[bearer(token)], finish_body("success"), ) .await; assert_eq!(sent.status, 200, "{}", sent.json()); let jobs = env.store.jobs_for_run(&run.id).await.unwrap(); let state_of = |name: &str| -> JobState { jobs.iter().find(|j: &&CiJob| j.name == name).unwrap().state }; assert_eq!(state_of("a"), JobState::Success); assert_eq!(state_of("b"), JobState::Queued); let next = claim_job(&env.router, &secret).await; assert_eq!(next["job_name"], "b"); } #[tokio::test] async fn finish_skips_dependants_on_failure() { let env = bare(|_| {}).await; let (_, secret) = runner(&env, &[]).await; let run = two_step_run(&env).await; let assignment = claim_job(&env.router, &secret).await; let job_id = assignment["job_id"].as_str().unwrap(); let token = assignment["job_token"].as_str().unwrap(); let sent = send( &env.router, "POST", &format!("/api/v1/ci/jobs/{job_id}/finish"), &[bearer(token)], finish_body("failure"), ) .await; assert_eq!(sent.status, 200, "{}", sent.json()); assert_eq!(sent.json()["state"], "failure"); let jobs = env.store.jobs_for_run(&run.id).await.unwrap(); let b = jobs.iter().find(|j| j.name == "b").unwrap(); assert_eq!(b.state, JobState::Skipped); let run_now = env.store.run_by_id(&run.id).await.unwrap().unwrap(); assert_eq!(run_now.state.as_str(), "failure"); // A finished job's token is refused. let again = send( &env.router, "POST", &format!("/api/v1/ci/jobs/{job_id}/finish"), &[bearer(token)], finish_body("success"), ) .await; assert_eq!(again.status, 401); } // --------------------------------------------------------------------------------------------- // Git read with a job token // --------------------------------------------------------------------------------------------- fn basic(user: &str, password: &str) -> (&'static str, String) { use base64::prelude::{BASE64_STANDARD, Engine}; let encoded = BASE64_STANDARD.encode(format!("{user}:{password}")); (AUTHORIZATION.as_str(), format!("Basic {encoded}")) } #[tokio::test] async fn job_token_reads_over_smart_http_but_never_pushes() { let fx = Fixture::new(Visibility::Public).await; let repo = fx .store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .expect("repo"); let (_, secret) = { let token = fx .store .create_registration_token( &fx.store.user_by_username(OWNER).await.unwrap().unwrap().id, 24, ) .await .unwrap(); fx.store .register_runner(&token, "runner", &[]) .await .unwrap() }; let owner_id = fx.store.user_by_username(OWNER).await.unwrap().unwrap().id; fx.store .create_run( &repo.id, "push", "refs/heads/main", &fx.root, None, &owner_id, false, &[NewJob { name: "a", spec_json: &spec(&[], &[]), needs: &[], }], None, ) .await .unwrap(); let runner = fx .store .runner_by_secret(&secret) .await .unwrap() .expect("runner"); let (job, _run, job_token) = fx .store .claim_job(&runner) .await .unwrap() .expect("claimed job"); let password = format!("{}:{job_token}", job.id); let info = |service: &str| format!("/{OWNER}/{REPO}/info/refs?service={service}"); let read = send( &fx.router, "GET", &info("git-upload-pack"), &[basic("ci-job", &password)], Vec::new(), ) .await; assert_eq!(read.status, 200, "job token should read"); let push = send( &fx.router, "GET", &info("git-receive-pack"), &[basic("ci-job", &password)], Vec::new(), ) .await; assert_eq!( push.status, 403, "job token must not advertise receive-pack" ); let post_push = send( &fx.router, "POST", &format!("/{OWNER}/{REPO}/git-receive-pack"), &[ basic("ci-job", &password), ( CONTENT_TYPE.as_str(), "application/x-git-receive-pack-request".into(), ), ], Vec::new(), ) .await; assert_eq!(post_push.status, 403, "job token must not push"); let wrong = send( &fx.router, "GET", &info("git-upload-pack"), &[basic("ci-job", &format!("{}:nope", job.id))], Vec::new(), ) .await; assert_eq!(wrong.status, 401); // Finishing the job ends the credential. fx.store .finish_job(&job.id, JobState::Success, Some(0), None) .await .unwrap(); let after = send( &fx.router, "GET", &info("git-upload-pack"), &[basic("ci-job", &password)], Vec::new(), ) .await; assert_eq!( after.status, 401, "a finished job's token must stop working" ); }