forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
26.8 KiB
1//! Route tests for the runner and job API. Each test drives the public router, the same one the
2//! server builds, so routing, auth and status codes are covered end to end.
3
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use forge_core::ci::{CiJob, CiRun, CiRunner, JobState, NewJob};
8use forge_core::{Repo, Store, Visibility};
9use http::header::{AUTHORIZATION, CONTENT_TYPE};
10use serde_json::{Value, json};
11use topcoat::router::request::Request;
12use topcoat::router::{Body, Router, to_bytes};
13
14use super::{artifact_key, fit_to_cap, log_path, valid_artifact_name};
15use crate::pages::commits::test_support::{Fixture, OWNER, REPO};
16use crate::test_support::{app_router, test_state};
17
18/// A response: status, headers and raw body.
19struct Sent {
20 status: u16,
21 headers: http::HeaderMap,
22 body: Vec<u8>,
23}
24
25impl Sent {
26 fn json(&self) -> Value {
27 serde_json::from_slice(&self.body).unwrap_or(Value::Null)
28 }
29}
30
31/// Sends one request through the router.
32async fn send(
33 router: &Router,
34 method: &str,
35 uri: &str,
36 headers: &[(&str, String)],
37 body: Vec<u8>,
38) -> Sent {
39 let mut builder = Request::builder().method(method).uri(uri);
40 for (name, value) in headers {
41 builder = builder.header(*name, value.as_str());
42 }
43 let req = builder.body(Body::from(body)).unwrap();
44 let res = router.handle(req).await;
45 let status = res.status().as_u16();
46 let headers = res.headers().clone();
47 let body = to_bytes(res.into_body(), usize::MAX)
48 .await
49 .unwrap()
50 .to_vec();
51 Sent {
52 status,
53 headers,
54 body,
55 }
56}
57
58fn bearer(token: &str) -> (&'static str, String) {
59 (AUTHORIZATION.as_str(), format!("Bearer {token}"))
60}
61
62fn json_body(value: &Value) -> Vec<u8> {
63 value.to_string().into_bytes()
64}
65
66/// A repo with an owner, and a router over a fresh store. `tune` adjusts the CI limits.
67struct Bare {
68 router: Router,
69 store: Store,
70 data_dir: PathBuf,
71 owner_id: String,
72 repo: Repo,
73 _dir: tempfile::TempDir,
74}
75
76async fn bare(tune: impl FnOnce(&mut forge_core::config::CiConfig)) -> Bare {
77 let dir = tempfile::tempdir().expect("tempdir");
78 let data_dir = dir.path().to_path_buf();
79 let mut state = test_state(&data_dir).await;
80 let mut config = (*state.config).clone();
81 tune(&mut config.ci);
82 state.config = Arc::new(config);
83 let store = state.store.clone();
84 let router = app_router(state);
85 let owner = store
86 .create_user(OWNER, None, "not-a-real-hash", false)
87 .await
88 .unwrap();
89 let repo = store
90 .create_repo(&owner.id, REPO, "", Visibility::Public, "main")
91 .await
92 .unwrap();
93 Bare {
94 router,
95 store,
96 data_dir,
97 owner_id: owner.id,
98 repo,
99 _dir: dir,
100 }
101}
102
103/// A runner registered through the store, with its secret.
104async fn runner(env: &Bare, labels: &[&str]) -> (CiRunner, String) {
105 let token = env
106 .store
107 .create_registration_token(&env.owner_id, 24)
108 .await
109 .unwrap();
110 let labels: Vec<String> = labels.iter().map(|label| (*label).to_owned()).collect();
111 env.store
112 .register_runner(&token, "runner", &labels)
113 .await
114 .unwrap()
115}
116
117/// A JobSpec as JSON. `runs_on` and `secrets` are the only fields the tests vary.
118fn spec(runs_on: &[&str], secrets: &[&str]) -> String {
119 json!({
120 "image": "alpine:3",
121 "run": "echo hi",
122 "needs": [],
123 "secrets": secrets,
124 "env": {},
125 "artifacts": [],
126 "runs_on": runs_on,
127 "timeout_minutes": 60,
128 "network": true,
129 })
130 .to_string()
131}
132
133/// Creates a run. `jobs` are (name, spec, needs).
134async fn make_run(
135 env: &Bare,
136 trigger: &str,
137 pull: Option<i64>,
138 commit: &str,
139 jobs: &[(&str, String, Vec<String>)],
140) -> CiRun {
141 let new: Vec<NewJob> = jobs
142 .iter()
143 .map(|(name, spec_json, needs)| NewJob {
144 name,
145 spec_json,
146 needs,
147 })
148 .collect();
149 env.store
150 .create_run(
151 &env.repo.id,
152 trigger,
153 "refs/heads/main",
154 commit,
155 pull,
156 &env.owner_id,
157 false,
158 &new,
159 None,
160 )
161 .await
162 .unwrap()
163}
164
165fn job(name: &str, spec_json: String) -> (&str, String, Vec<String>) {
166 (name, spec_json, Vec::new())
167}
168
169const COMMIT: &str = "1111111111111111111111111111111111111111";
170
171async fn claim(router: &Router, secret: &str) -> Sent {
172 send(
173 router,
174 "POST",
175 "/api/v1/ci/runners/claim",
176 &[bearer(secret)],
177 b"{}".to_vec(),
178 )
179 .await
180}
181
182/// Claims a job for `secret` and returns the assignment JSON.
183async fn claim_job(router: &Router, secret: &str) -> Value {
184 let sent = claim(router, secret).await;
185 assert_eq!(sent.status, 200, "{}", sent.json());
186 sent.json()
187}
188
189fn log_post(
190 job_id: &str,
191 token: &str,
192 offset: u64,
193 body: &[u8],
194) -> (String, Vec<(&'static str, String)>, Vec<u8>) {
195 (
196 format!("/api/v1/ci/jobs/{job_id}/logs"),
197 vec![bearer(token), ("x-forge-log-offset", offset.to_string())],
198 body.to_vec(),
199 )
200}
201
202// ---------------------------------------------------------------------------------------------
203// Registration
204// ---------------------------------------------------------------------------------------------
205
206#[tokio::test]
207async fn register_good_and_reused_tokens() {
208 let env = bare(|_| {}).await;
209 let uri = "/api/v1/ci/runners/register";
210 let good = env
211 .store
212 .create_registration_token(&env.owner_id, 24)
213 .await
214 .unwrap();
215 let body = json!({ "token": good, "name": "linux-1", "labels": ["linux"] });
216
217 let sent = send(&env.router, "POST", uri, &[], json_body(&body)).await;
218 assert_eq!(sent.status, 201, "{}", sent.json());
219 assert!(
220 sent.json()["runner_secret"]
221 .as_str()
222 .is_some_and(|s| !s.is_empty())
223 );
224 assert!(sent.json()["runner_id"].as_str().is_some());
225
226 let reused = send(&env.router, "POST", uri, &[], json_body(&body)).await;
227 assert_eq!(reused.status, 401, "{}", reused.json());
228}
229
230#[tokio::test]
231async fn register_rejects_bad_names() {
232 let env = bare(|_| {}).await;
233 let body = json!({ "token": "unused", "name": " ", "labels": [] });
234 let sent = send(
235 &env.router,
236 "POST",
237 "/api/v1/ci/runners/register",
238 &[],
239 json_body(&body),
240 )
241 .await;
242 assert_eq!(sent.status, 422, "{}", sent.json());
243}
244
245// ---------------------------------------------------------------------------------------------
246// Claims
247// ---------------------------------------------------------------------------------------------
248
249#[tokio::test]
250async fn claim_is_204_without_work_and_200_with_work() {
251 let env = bare(|_| {}).await;
252 let (_, secret) = runner(&env, &["linux"]).await;
253 let sent = claim(&env.router, &secret).await;
254 assert_eq!(sent.status, 204);
255
256 make_run(&env, "push", None, COMMIT, &[job("build", spec(&[], &[]))]).await;
257 let assignment = claim_job(&env.router, &secret).await;
258 assert_eq!(assignment["job_name"], "build");
259 assert_eq!(assignment["repo"], format!("{OWNER}/{REPO}"));
260 assert_eq!(assignment["commit"], COMMIT);
261 assert_eq!(assignment["image"], "alpine:3");
262 assert!(
263 assignment["job_token"]
264 .as_str()
265 .is_some_and(|t| !t.is_empty())
266 );
267 assert!(
268 assignment["clone_url"]
269 .as_str()
270 .is_some_and(|u| u.ends_with(&format!("/{OWNER}/{REPO}.git")))
271 );
272}
273
274#[tokio::test]
275async fn claim_skips_jobs_with_a_label_the_runner_lacks() {
276 let env = bare(|_| {}).await;
277 let (_, secret) = runner(&env, &["linux"]).await;
278 make_run(
279 &env,
280 "push",
281 None,
282 COMMIT,
283 &[job("gpu", spec(&["gpu"], &[]))],
284 )
285 .await;
286 let sent = claim(&env.router, &secret).await;
287 assert_eq!(sent.status, 204);
288}
289
290#[tokio::test]
291async fn disabled_runner_gets_403() {
292 let env = bare(|_| {}).await;
293 let (runner, secret) = runner(&env, &["linux"]).await;
294 env.store
295 .set_runner_disabled(&runner.id, true)
296 .await
297 .unwrap();
298 let sent = claim(&env.router, &secret).await;
299 assert_eq!(sent.status, 403, "{}", sent.json());
300}
301
302#[tokio::test]
303async fn unknown_runner_secret_is_401() {
304 let env = bare(|_| {}).await;
305 let sent = claim(&env.router, "not-a-secret").await;
306 assert_eq!(sent.status, 401);
307}
308
309// ---------------------------------------------------------------------------------------------
310// Secrets on fork-PR runs
311// ---------------------------------------------------------------------------------------------
312
313#[tokio::test]
314async fn fork_pr_runs_get_no_secrets() {
315 let env = bare(|_| {}).await;
316 let (_, secret) = runner(&env, &[]).await;
317 env.store
318 .set_ci_secret(&env.repo.id, "DEPLOY", b"s3cret-value")
319 .await
320 .unwrap();
321
322 make_run(
323 &env,
324 "push",
325 None,
326 COMMIT,
327 &[job("deploy", spec(&[], &["DEPLOY"]))],
328 )
329 .await;
330 let trusted = claim_job(&env.router, &secret).await;
331 assert_eq!(trusted["secrets"]["DEPLOY"], "s3cret-value");
332
333 // A pull request with no pull row behaves as a fork: nothing is handed out.
334 make_run(
335 &env,
336 "pull_request",
337 None,
338 COMMIT,
339 &[job("deploy", spec(&[], &["DEPLOY"]))],
340 )
341 .await;
342 let fork = claim_job(&env.router, &secret).await;
343 assert_eq!(fork["secrets"], json!({}));
344}
345
346// ---------------------------------------------------------------------------------------------
347// Logs
348// ---------------------------------------------------------------------------------------------
349
350#[tokio::test]
351async fn logs_append_retry_mismatch_and_mask() {
352 let env = bare(|_| {}).await;
353 let (_, secret) = runner(&env, &[]).await;
354 env.store
355 .set_ci_secret(&env.repo.id, "TOKEN", b"hunter2-token")
356 .await
357 .unwrap();
358 make_run(
359 &env,
360 "push",
361 None,
362 COMMIT,
363 &[job("test", spec(&[], &["TOKEN"]))],
364 )
365 .await;
366 let assignment = claim_job(&env.router, &secret).await;
367 let job_id = assignment["job_id"].as_str().unwrap().to_owned();
368 let token = assignment["job_token"].as_str().unwrap().to_owned();
369 let path = log_path(&env.data_dir, &job_id);
370
371 let first = b"hello hunter2-token world".to_vec();
372 let (uri, headers, body) = log_post(&job_id, &token, 0, &first);
373 let sent = send(&env.router, "POST", &uri, &headers, body).await;
374 assert_eq!(sent.status, 200, "{}", sent.json());
375 let stored = std::fs::read_to_string(&path).unwrap();
376 assert_eq!(stored, "hello *** world");
377
378 // A retry of the same chunk at the same offset is accepted and writes nothing.
379 let (uri, headers, body) = log_post(&job_id, &token, 0, &first);
380 let sent = send(&env.router, "POST", &uri, &headers, body).await;
381 assert_eq!(sent.status, 200);
382 assert_eq!(std::fs::read_to_string(&path).unwrap(), stored);
383
384 // A different chunk at an old offset is a mismatch and says where the log really ends.
385 let (uri, headers, body) = log_post(&job_id, &token, 0, b"x");
386 let sent = send(&env.router, "POST", &uri, &headers, body).await;
387 assert_eq!(sent.status, 409);
388 assert_eq!(
389 sent.headers
390 .get("x-forge-log-size")
391 .and_then(|v| v.to_str().ok()),
392 Some(first.len().to_string().as_str())
393 );
394
395 // The next chunk, at the end, is stored.
396 let (uri, headers, body) = log_post(&job_id, &token, first.len() as u64, b"\nmore\n");
397 let sent = send(&env.router, "POST", &uri, &headers, body).await;
398 assert_eq!(sent.status, 200);
399 assert_eq!(
400 std::fs::read_to_string(&path).unwrap(),
401 "hello *** world\nmore\n"
402 );
403}
404
405#[tokio::test]
406async fn log_offset_header_is_required() {
407 let env = bare(|_| {}).await;
408 let (_, secret) = runner(&env, &[]).await;
409 make_run(&env, "push", None, COMMIT, &[job("a", spec(&[], &[]))]).await;
410 let assignment = claim_job(&env.router, &secret).await;
411 let job_id = assignment["job_id"].as_str().unwrap();
412 let token = assignment["job_token"].as_str().unwrap();
413 let sent = send(
414 &env.router,
415 "POST",
416 &format!("/api/v1/ci/jobs/{job_id}/logs"),
417 &[bearer(token)],
418 b"text".to_vec(),
419 )
420 .await;
421 assert_eq!(sent.status, 400);
422}
423
424#[test]
425fn log_cap_keeps_what_fits_then_one_marker() {
426 // 100 byte cap. The marker is 17 bytes, so 83 bytes of data fit.
427 let out = fit_to_cap(0, 100, &[b'a'; 150]);
428 assert_eq!(out.len(), 100);
429 assert!(out.ends_with(b"[log truncated]\n"));
430 // Room is left: a short chunk is kept whole.
431 assert_eq!(fit_to_cap(10, 100, b"hello"), b"hello");
432 // The cap is reached: nothing more, not even a second marker.
433 assert!(fit_to_cap(100, 100, b"more").is_empty());
434 // Exactly full at the data limit: the next bytes bring the marker, and only that.
435 assert_eq!(fit_to_cap(83, 100, b"x"), b"\n[log truncated]\n");
436}
437
438// ---------------------------------------------------------------------------------------------
439// Heartbeat
440// ---------------------------------------------------------------------------------------------
441
442#[tokio::test]
443async fn heartbeat_reports_a_cancel_request() {
444 let env = bare(|_| {}).await;
445 let (_, secret) = runner(&env, &[]).await;
446 let run = make_run(&env, "push", None, COMMIT, &[job("a", spec(&[], &[]))]).await;
447 let assignment = claim_job(&env.router, &secret).await;
448 let job_id = assignment["job_id"].as_str().unwrap();
449 let token = assignment["job_token"].as_str().unwrap();
450 let uri = format!("/api/v1/ci/jobs/{job_id}/heartbeat");
451
452 let sent = send(&env.router, "POST", &uri, &[bearer(token)], Vec::new()).await;
453 assert_eq!(sent.status, 200);
454 assert_eq!(sent.json()["cancel"], false);
455
456 env.store.request_cancel(&run.id).await.unwrap();
457 let sent = send(&env.router, "POST", &uri, &[bearer(token)], Vec::new()).await;
458 assert_eq!(sent.status, 200);
459 assert_eq!(sent.json()["cancel"], true);
460
461 let sent = send(&env.router, "POST", &uri, &[bearer("wrong")], Vec::new()).await;
462 assert_eq!(sent.status, 401);
463}
464
465// ---------------------------------------------------------------------------------------------
466// Artifacts
467// ---------------------------------------------------------------------------------------------
468
469#[test]
470fn artifact_names_are_validated() {
471 assert!(valid_artifact_name("out.tar.gz"));
472 assert!(valid_artifact_name("a+b_c-1"));
473 assert!(!valid_artifact_name(""));
474 assert!(!valid_artifact_name(".hidden"));
475 assert!(!valid_artifact_name("a b"));
476 assert!(!valid_artifact_name("a/b"));
477 assert!(!valid_artifact_name(&"a".repeat(129)));
478}
479
480async fn put_artifact(
481 router: &Router,
482 job_id: &str,
483 token: &str,
484 name: &str,
485 body: Vec<u8>,
486) -> Sent {
487 let uri = format!("/api/v1/ci/jobs/{job_id}/artifacts/{name}");
488 send(router, "PUT", &uri, &[bearer(token)], body).await
489}
490
491#[tokio::test]
492async fn artifact_upload_stores_and_checks_name_count_and_size() {
493 let env = bare(|ci| {
494 ci.max_artifacts_per_job = 3;
495 ci.max_artifact_mb = 1;
496 })
497 .await;
498 let (_, secret) = runner(&env, &[]).await;
499 make_run(&env, "push", None, COMMIT, &[job("a", spec(&[], &[]))]).await;
500 let assignment = claim_job(&env.router, &secret).await;
501 let job_id = assignment["job_id"].as_str().unwrap().to_owned();
502 let token = assignment["job_token"].as_str().unwrap().to_owned();
503
504 let bad = put_artifact(&env.router, &job_id, &token, ".hidden", b"x".to_vec()).await;
505 assert_eq!(bad.status, 422);
506
507 let ok = put_artifact(&env.router, &job_id, &token, "out.txt", b"abc".to_vec()).await;
508 assert_eq!(ok.status, 201, "{}", ok.json());
509 let stored = env
510 .store
511 .artifact(&job_id, "out.txt")
512 .await
513 .unwrap()
514 .expect("artifact row");
515 assert_eq!(stored.size, 3);
516 assert_eq!(
517 stored.sha256,
518 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
519 );
520 assert_eq!(artifact_key(&stored.id), format!("ci/{}", stored.id));
521
522 let again = put_artifact(&env.router, &job_id, &token, "out.txt", b"abc".to_vec()).await;
523 assert_eq!(again.status, 409);
524
525 let second = put_artifact(&env.router, &job_id, &token, "two.txt", b"two".to_vec()).await;
526 assert_eq!(second.status, 201);
527
528 let too_big = put_artifact(
529 &env.router,
530 &job_id,
531 &token,
532 "big.bin",
533 vec![0u8; 1024 * 1024 + 1],
534 )
535 .await;
536 assert_eq!(too_big.status, 413);
537
538 let third = put_artifact(&env.router, &job_id, &token, "three.txt", b"three".to_vec()).await;
539 assert_eq!(third.status, 201);
540 let fourth = put_artifact(&env.router, &job_id, &token, "four.txt", b"four".to_vec()).await;
541 assert_eq!(fourth.status, 409, "the count cap is 3");
542}
543
544// ---------------------------------------------------------------------------------------------
545// Finish
546// ---------------------------------------------------------------------------------------------
547
548fn finish_body(outcome: &str) -> Vec<u8> {
549 json_body(&json!({ "outcome": outcome, "exit_code": 0, "message": null }))
550}
551
552async fn two_step_run(env: &Bare) -> CiRun {
553 make_run(
554 env,
555 "push",
556 None,
557 COMMIT,
558 &[
559 job("a", spec(&[], &[])),
560 (
561 "b",
562 spec(&[], &[]).replace("\"needs\":[]", "\"needs\":[\"a\"]"),
563 vec!["a".to_owned()],
564 ),
565 ],
566 )
567 .await
568}
569
570#[tokio::test]
571async fn finish_queues_dependants_on_success() {
572 let env = bare(|_| {}).await;
573 let (_, secret) = runner(&env, &[]).await;
574 let run = two_step_run(&env).await;
575 let assignment = claim_job(&env.router, &secret).await;
576 assert_eq!(assignment["job_name"], "a");
577 let job_id = assignment["job_id"].as_str().unwrap();
578 let token = assignment["job_token"].as_str().unwrap();
579
580 let sent = send(
581 &env.router,
582 "POST",
583 &format!("/api/v1/ci/jobs/{job_id}/finish"),
584 &[bearer(token)],
585 finish_body("success"),
586 )
587 .await;
588 assert_eq!(sent.status, 200, "{}", sent.json());
589
590 let jobs = env.store.jobs_for_run(&run.id).await.unwrap();
591 let state_of =
592 |name: &str| -> JobState { jobs.iter().find(|j: &&CiJob| j.name == name).unwrap().state };
593 assert_eq!(state_of("a"), JobState::Success);
594 assert_eq!(state_of("b"), JobState::Queued);
595 let next = claim_job(&env.router, &secret).await;
596 assert_eq!(next["job_name"], "b");
597}
598
599#[tokio::test]
600async fn finish_skips_dependants_on_failure() {
601 let env = bare(|_| {}).await;
602 let (_, secret) = runner(&env, &[]).await;
603 let run = two_step_run(&env).await;
604 let assignment = claim_job(&env.router, &secret).await;
605 let job_id = assignment["job_id"].as_str().unwrap();
606 let token = assignment["job_token"].as_str().unwrap();
607
608 let sent = send(
609 &env.router,
610 "POST",
611 &format!("/api/v1/ci/jobs/{job_id}/finish"),
612 &[bearer(token)],
613 finish_body("failure"),
614 )
615 .await;
616 assert_eq!(sent.status, 200, "{}", sent.json());
617 assert_eq!(sent.json()["state"], "failure");
618
619 let jobs = env.store.jobs_for_run(&run.id).await.unwrap();
620 let b = jobs.iter().find(|j| j.name == "b").unwrap();
621 assert_eq!(b.state, JobState::Skipped);
622 let run_now = env.store.run_by_id(&run.id).await.unwrap().unwrap();
623 assert_eq!(run_now.state.as_str(), "failure");
624
625 // A finished job's token is refused.
626 let again = send(
627 &env.router,
628 "POST",
629 &format!("/api/v1/ci/jobs/{job_id}/finish"),
630 &[bearer(token)],
631 finish_body("success"),
632 )
633 .await;
634 assert_eq!(again.status, 401);
635}
636
637// ---------------------------------------------------------------------------------------------
638// Git read with a job token
639// ---------------------------------------------------------------------------------------------
640
641fn basic(user: &str, password: &str) -> (&'static str, String) {
642 use base64::prelude::{BASE64_STANDARD, Engine};
643 let encoded = BASE64_STANDARD.encode(format!("{user}:{password}"));
644 (AUTHORIZATION.as_str(), format!("Basic {encoded}"))
645}
646
647#[tokio::test]
648async fn job_token_reads_over_smart_http_but_never_pushes() {
649 let fx = Fixture::new(Visibility::Public).await;
650 let repo = fx
651 .store
652 .repo_by_owner_and_name(OWNER, REPO)
653 .await
654 .unwrap()
655 .expect("repo");
656 let (_, secret) = {
657 let token = fx
658 .store
659 .create_registration_token(
660 &fx.store.user_by_username(OWNER).await.unwrap().unwrap().id,
661 24,
662 )
663 .await
664 .unwrap();
665 fx.store
666 .register_runner(&token, "runner", &[])
667 .await
668 .unwrap()
669 };
670 let owner_id = fx.store.user_by_username(OWNER).await.unwrap().unwrap().id;
671 fx.store
672 .create_run(
673 &repo.id,
674 "push",
675 "refs/heads/main",
676 &fx.root,
677 None,
678 &owner_id,
679 false,
680 &[NewJob {
681 name: "a",
682 spec_json: &spec(&[], &[]),
683 needs: &[],
684 }],
685 None,
686 )
687 .await
688 .unwrap();
689 let runner = fx
690 .store
691 .runner_by_secret(&secret)
692 .await
693 .unwrap()
694 .expect("runner");
695 let (job, _run, job_token) = fx
696 .store
697 .claim_job(&runner)
698 .await
699 .unwrap()
700 .expect("claimed job");
701
702 let password = format!("{}:{job_token}", job.id);
703 let info = |service: &str| format!("/{OWNER}/{REPO}/info/refs?service={service}");
704
705 let read = send(
706 &fx.router,
707 "GET",
708 &info("git-upload-pack"),
709 &[basic("ci-job", &password)],
710 Vec::new(),
711 )
712 .await;
713 assert_eq!(read.status, 200, "job token should read");
714
715 let push = send(
716 &fx.router,
717 "GET",
718 &info("git-receive-pack"),
719 &[basic("ci-job", &password)],
720 Vec::new(),
721 )
722 .await;
723 assert_eq!(
724 push.status, 403,
725 "job token must not advertise receive-pack"
726 );
727
728 let post_push = send(
729 &fx.router,
730 "POST",
731 &format!("/{OWNER}/{REPO}/git-receive-pack"),
732 &[
733 basic("ci-job", &password),
734 (
735 CONTENT_TYPE.as_str(),
736 "application/x-git-receive-pack-request".into(),
737 ),
738 ],
739 Vec::new(),
740 )
741 .await;
742 assert_eq!(post_push.status, 403, "job token must not push");
743
744 let wrong = send(
745 &fx.router,
746 "GET",
747 &info("git-upload-pack"),
748 &[basic("ci-job", &format!("{}:nope", job.id))],
749 Vec::new(),
750 )
751 .await;
752 assert_eq!(wrong.status, 401);
753
754 // Finishing the job ends the credential.
755 fx.store
756 .finish_job(&job.id, JobState::Success, Some(0), None)
757 .await
758 .unwrap();
759 let after = send(
760 &fx.router,
761 "GET",
762 &info("git-upload-pack"),
763 &[basic("ci-job", &password)],
764 Vec::new(),
765 )
766 .await;
767 assert_eq!(
768 after.status, 401,
769 "a finished job's token must stop working"
770 );
771}
772
773/// Like `bare`, with self-update enabled for `OWNER/REPO`.
774async fn bare_with_self_update() -> Bare {
775 let dir = tempfile::tempdir().expect("tempdir");
776 let data_dir = dir.path().to_path_buf();
777 let mut state = test_state(&data_dir).await;
778 let mut config = (*state.config).clone();
779 config.self_update.enabled = true;
780 config.self_update.repo = format!("{OWNER}/{REPO}");
781 state.config = Arc::new(config);
782 let store = state.store.clone();
783 let router = app_router(state);
784 let owner = store
785 .create_user(OWNER, None, "not-a-real-hash", false)
786 .await
787 .unwrap();
788 let repo = store
789 .create_repo(&owner.id, REPO, "", Visibility::Public, "main")
790 .await
791 .unwrap();
792 Bare {
793 router,
794 store,
795 data_dir,
796 owner_id: owner.id,
797 repo,
798 _dir: dir,
799 }
800}
801
802#[tokio::test]
803async fn runner_binary_needs_runner_auth_and_self_update() {
804 let env = bare(|_| {}).await;
805 let (_, secret) = runner(&env, &[]).await;
806 let uri = "/api/v1/ci/runners/binary";
807
808 let sent = send(&env.router, "GET", uri, &[], Vec::new()).await;
809 assert_eq!(sent.status, 401);
810 // Self-update is off in this fixture, so an authorized runner gets 404.
811 let sent = send(&env.router, "GET", uri, &[bearer(&secret)], Vec::new()).await;
812 assert_eq!(sent.status, 404);
813}
814
815#[tokio::test]
816async fn runner_binary_streams_the_running_binary_with_its_sha() {
817 use sha2::{Digest, Sha256};
818
819 let env = bare_with_self_update().await;
820 let (_, secret) = runner(&env, &[]).await;
821 let uri = "/api/v1/ci/runners/binary";
822
823 let sent = send(
824 &env.router,
825 "GET",
826 uri,
827 &[bearer("not-a-secret")],
828 Vec::new(),
829 )
830 .await;
831 assert_eq!(sent.status, 401);
832
833 let sent = send(&env.router, "GET", uri, &[bearer(&secret)], Vec::new()).await;
834 assert_eq!(sent.status, 200);
835 assert_eq!(
836 sent.headers.get(http::header::CONTENT_TYPE).unwrap(),
837 "application/octet-stream"
838 );
839 let advertised = sent
840 .headers
841 .get("forge-binary-sha256")
842 .unwrap()
843 .to_str()
844 .unwrap();
845 let digest = hex::encode(Sha256::digest(&sent.body));
846 assert_eq!(digest, advertised);
847 assert_eq!(
848 advertised,
849 crate::self_update::running()
850 .expect("hashed at startup")
851 .sha256
852 );
853}
854
855#[tokio::test]
856async fn claim_advertises_the_binary_only_to_a_matching_target() {
857 let env = bare_with_self_update().await;
858 let (_, secret) = runner(&env, &["linux"]).await;
859 let target = crate::self_update::target();
860 let claim_with = |target: Option<String>| {
861 let mut headers = vec![bearer(&secret)];
862 if let Some(target) = target {
863 headers.push(("Forge-Runner-Target", target));
864 }
865 headers
866 };
867
868 // A 204 (no work) carries the header for a matching target.
869 let sent = send(
870 &env.router,
871 "POST",
872 "/api/v1/ci/runners/claim",
873 &claim_with(Some(target.clone())),
874 Vec::new(),
875 )
876 .await;
877 assert_eq!(sent.status, 204);
878 let running = crate::self_update::running().expect("hashed at startup");
879 assert_eq!(
880 sent.headers
881 .get("forge-binary-sha256")
882 .unwrap()
883 .to_str()
884 .unwrap(),
885 running.sha256
886 );
887
888 // No target, or another target: no header.
889 for requested in [None, Some("riscv64-plan9".to_owned())] {
890 let sent = send(
891 &env.router,
892 "POST",
893 "/api/v1/ci/runners/claim",
894 &claim_with(requested),
895 Vec::new(),
896 )
897 .await;
898 assert_eq!(sent.status, 204);
899 assert!(sent.headers.get("forge-binary-sha256").is_none());
900 }
901}