forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
17.1 KiB
1//! Route tests for the CI run and job pages. The fixture: alice owns the private repo `proj`, bob
2//! has Write, dave has Read, and carol is outside the repo.
3
4use std::path::{Path, PathBuf};
5
6use forge_core::ci::{CiJob, CiRun, JobState, NewJob, RunState};
7use forge_core::{Role, Store, Visibility};
8use forge_git::repo::{init_bare, repo_path};
9use topcoat::router::request::Request;
10use topcoat::router::{Body, HeaderMap, Router, to_bytes};
11
12use crate::auth::SESSION_COOKIE;
13use crate::blobs::BlobStore;
14use crate::ci::{artifact_key, log_path};
15use crate::test_support::{app_router, create_session, test_state};
16
17const CSRF: &str = "ci-test-csrf";
18const SHA: &str = "0123456789abcdef0123456789abcdef01234567";
19const SPEC: &str = r#"{"runs_on":[]}"#;
20const RUN_BASE: &str = "/alice/proj/ci";
21
22struct Fixture {
23 store: Store,
24 app: Router,
25 repo: forge_core::Repo,
26 alice: String,
27 data_dir: PathBuf,
28 blobs: BlobStore,
29}
30
31async fn fixture(dir: &Path) -> Fixture {
32 let state = test_state(dir).await;
33 let store = state.store.clone();
34 let alice = store
35 .create_user("alice", None, "unused", false)
36 .await
37 .expect("alice");
38 let bob = store
39 .create_user("bob", None, "unused", false)
40 .await
41 .expect("bob");
42 let dave = store
43 .create_user("dave", None, "unused", false)
44 .await
45 .expect("dave");
46 let carol = store
47 .create_user("carol", None, "unused", false)
48 .await
49 .expect("carol");
50 let repo = store
51 .create_repo(&alice.id, "proj", "", Visibility::Private, "main")
52 .await
53 .expect("repo");
54 let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path");
55 init_bare(&state.git, &bare, "main")
56 .await
57 .expect("init bare");
58 store
59 .set_collaborator(&repo.id, &bob.id, Role::Write)
60 .await
61 .expect("bob writes");
62 store
63 .set_collaborator(&repo.id, &dave.id, Role::Read)
64 .await
65 .expect("dave reads");
66 create_session(&store, &alice.id, "alice-token", CSRF).await;
67 create_session(&store, &bob.id, "bob-token", CSRF).await;
68 create_session(&store, &dave.id, "dave-token", CSRF).await;
69 create_session(&store, &carol.id, "carol-token", CSRF).await;
70 let data_dir = state.config.data_dir.clone();
71 let blobs = state.blobs.clone();
72 Fixture {
73 store,
74 app: app_router(state),
75 repo,
76 alice: alice.id,
77 data_dir,
78 blobs,
79 }
80}
81
82fn cookie_for(token: &str) -> String {
83 format!("__Host-{SESSION_COOKIE}={token}")
84}
85
86/// A response: status, headers and the whole body.
87struct Reply {
88 status: u16,
89 headers: HeaderMap,
90 body: Vec<u8>,
91}
92
93impl Reply {
94 fn text(&self) -> String {
95 String::from_utf8_lossy(&self.body).into_owned()
96 }
97
98 fn location(&self) -> Option<String> {
99 self.headers
100 .get("location")
101 .and_then(|value| value.to_str().ok())
102 .map(str::to_owned)
103 }
104}
105
106async fn get(app: &Router, uri: &str, cookie: Option<&str>) -> Reply {
107 let mut builder = Request::builder().method("GET").uri(uri);
108 if let Some(cookie) = cookie {
109 builder = builder.header("cookie", cookie);
110 }
111 let req = builder.body(Body::from(String::new())).expect("request");
112 collect(app.handle(req).await).await
113}
114
115/// A POST with the CSRF token and the given form fields.
116async fn post(app: &Router, uri: &str, cookie: &str, fields: &[(&str, &str)]) -> Reply {
117 let mut form = form_urlencoded::Serializer::new(String::new());
118 form.append_pair("csrf", CSRF);
119 for (name, value) in fields {
120 form.append_pair(name, value);
121 }
122 let req = Request::builder()
123 .method("POST")
124 .uri(uri)
125 .header("cookie", cookie)
126 .header("content-type", "application/x-www-form-urlencoded")
127 .body(Body::from(form.finish()))
128 .expect("request");
129 collect(app.handle(req).await).await
130}
131
132async fn collect(res: topcoat::router::response::Response) -> Reply {
133 let status = res.status().as_u16();
134 let headers = res.headers().clone();
135 let body = to_bytes(res.into_body(), usize::MAX).await.expect("body");
136 Reply {
137 status,
138 headers,
139 body: body.to_vec(),
140 }
141}
142
143/// Creates a run on `main` with one job per name. Jobs wait for approval when `needs_approval`.
144async fn run_with(fx: &Fixture, jobs: &[&str], needs_approval: bool) -> CiRun {
145 let needs: &[String] = &[];
146 let specs: Vec<NewJob<'_>> = jobs
147 .iter()
148 .map(|name| NewJob {
149 name,
150 spec_json: SPEC,
151 needs,
152 })
153 .collect();
154 fx.store
155 .create_run(
156 &fx.repo.id,
157 "push",
158 "refs/heads/main",
159 SHA,
160 None,
161 &fx.alice,
162 needs_approval,
163 &specs,
164 None,
165 )
166 .await
167 .expect("run")
168}
169
170/// Claims the one queued job with a fresh runner and finishes it as a success.
171async fn finish_job(fx: &Fixture) -> CiJob {
172 let token = fx
173 .store
174 .create_registration_token(&fx.alice, 24)
175 .await
176 .expect("token");
177 let (runner, _) = fx
178 .store
179 .register_runner(&token, "runner-1", &["linux".to_owned()])
180 .await
181 .expect("runner");
182 let (job, _, _) = fx
183 .store
184 .claim_job(&runner)
185 .await
186 .expect("claim")
187 .expect("a queued job");
188 fx.store
189 .finish_job(&job.id, JobState::Success, Some(0), None)
190 .await
191 .expect("finish");
192 job
193}
194
195async fn job_of(fx: &Fixture, run: &CiRun) -> CiJob {
196 fx.store
197 .jobs_for_run(&run.id)
198 .await
199 .expect("jobs")
200 .into_iter()
201 .next()
202 .expect("a job")
203}
204
205#[tokio::test]
206async fn private_repo_is_not_found_to_outsiders_and_anonymous() {
207 let dir = tempfile::tempdir().expect("tempdir");
208 let fx = fixture(dir.path()).await;
209 let run = run_with(&fx, &["build"], false).await;
210 let job = job_of(&fx, &run).await;
211 let urls = [
212 RUN_BASE.to_owned(),
213 format!("{RUN_BASE}/runs/{}", run.number),
214 format!("/alice/proj/ci/jobs/{}", job.id),
215 format!("/alice/proj/ci/jobs/{}/log.txt", job.id),
216 format!("/alice/proj/ci/jobs/{}/artifacts/x.bin", job.id),
217 ];
218 for url in &urls {
219 let anon = get(&fx.app, url, None).await;
220 assert_eq!(anon.status, 404, "anonymous {url}");
221 let outsider = get(&fx.app, url, Some(&cookie_for("carol-token"))).await;
222 assert_eq!(outsider.status, 404, "outsider {url}");
223 }
224 let reader = get(&fx.app, RUN_BASE, Some(&cookie_for("dave-token"))).await;
225 assert_eq!(reader.status, 200);
226}
227
228#[tokio::test]
229async fn buttons_and_manual_form_need_write() {
230 let dir = tempfile::tempdir().expect("tempdir");
231 let fx = fixture(dir.path()).await;
232 let run = run_with(&fx, &["build"], false).await;
233 let run_url = format!("{RUN_BASE}/runs/{}", run.number);
234 let writer = get(&fx.app, &run_url, Some(&cookie_for("bob-token"))).await;
235 assert!(writer.text().contains(">Re-run<"), "writer sees re-run");
236 assert!(writer.text().contains(">Cancel<"), "writer sees cancel");
237 let reader = get(&fx.app, &run_url, Some(&cookie_for("dave-token"))).await;
238 assert_eq!(reader.status, 200);
239 assert!(!reader.text().contains(">Re-run<"), "reader sees no re-run");
240 assert!(!reader.text().contains(">Cancel<"), "reader sees no cancel");
241 let list = get(&fx.app, RUN_BASE, Some(&cookie_for("dave-token"))).await;
242 assert!(
243 !list.text().contains("Run workflow"),
244 "reader has no manual form"
245 );
246 let list = get(&fx.app, RUN_BASE, Some(&cookie_for("bob-token"))).await;
247 assert!(
248 list.text().contains("Run workflow"),
249 "writer has the manual form"
250 );
251 let refused = post(
252 &fx.app,
253 &format!("{run_url}/cancel"),
254 &cookie_for("dave-token"),
255 &[],
256 )
257 .await;
258 assert_eq!(refused.status, 403, "reader cannot cancel");
259 let still = fx
260 .store
261 .run_by_id(&run.id)
262 .await
263 .expect("run")
264 .expect("run");
265 assert_eq!(still.state, RunState::Queued);
266}
267
268#[tokio::test]
269async fn run_list_pages_25_at_a_time() {
270 let dir = tempfile::tempdir().expect("tempdir");
271 let fx = fixture(dir.path()).await;
272 for _ in 0..30 {
273 run_with(&fx, &[], false).await;
274 }
275 let first = get(&fx.app, RUN_BASE, Some(&cookie_for("alice-token"))).await;
276 let text = first.text();
277 assert_eq!(text.matches("href=\"/alice/proj/ci/runs/").count(), 25);
278 assert!(text.contains("?page=2"), "older link");
279 assert!(!text.contains(">Newer<"));
280 let second = get(
281 &fx.app,
282 &format!("{RUN_BASE}?page=2"),
283 Some(&cookie_for("alice-token")),
284 )
285 .await;
286 let text = second.text();
287 assert_eq!(text.matches("href=\"/alice/proj/ci/runs/").count(), 5);
288 assert!(text.contains(">Newer<"), "newer link");
289 assert!(!text.contains("?page=3"), "no older link on the last page");
290 let bogus = get(
291 &fx.app,
292 &format!("{RUN_BASE}?page=abc"),
293 Some(&cookie_for("alice-token")),
294 )
295 .await;
296 assert_eq!(bogus.status, 200);
297 assert_eq!(
298 bogus.text().matches("href=\"/alice/proj/ci/runs/").count(),
299 25
300 );
301}
302
303#[tokio::test]
304async fn meta_refresh_only_while_the_job_is_not_terminal() {
305 let dir = tempfile::tempdir().expect("tempdir");
306 let fx = fixture(dir.path()).await;
307 let run = run_with(&fx, &["build"], false).await;
308 let job = job_of(&fx, &run).await;
309 let url = format!("/alice/proj/ci/jobs/{}", job.id);
310 let queued = get(&fx.app, &url, Some(&cookie_for("alice-token"))).await;
311 assert!(
312 queued.text().contains("http-equiv=\"refresh\""),
313 "refresh while queued"
314 );
315 finish_job(&fx).await;
316 let done = get(&fx.app, &url, Some(&cookie_for("alice-token"))).await;
317 assert!(
318 !done.text().contains("http-equiv=\"refresh\""),
319 "no refresh when finished"
320 );
321}
322
323#[tokio::test]
324async fn job_page_tails_the_log_at_512_kib() {
325 let dir = tempfile::tempdir().expect("tempdir");
326 let fx = fixture(dir.path()).await;
327 let run = run_with(&fx, &["build"], false).await;
328 let job = job_of(&fx, &run).await;
329 let path = log_path(&fx.data_dir, &job.id);
330 std::fs::create_dir_all(path.parent().expect("log dir")).expect("log dir");
331 let mut body = b"HEAD-MARK\n".to_vec();
332 let line = format!("{}\n", "x".repeat(99));
333 for _ in 0..6_000 {
334 body.extend_from_slice(line.as_bytes());
335 }
336 body.extend_from_slice(b"TAIL-MARK\n");
337 std::fs::write(&path, &body).expect("log");
338 let page = get(
339 &fx.app,
340 &format!("/alice/proj/ci/jobs/{}", job.id),
341 Some(&cookie_for("alice-token")),
342 )
343 .await;
344 let text = page.text();
345 assert!(text.contains("TAIL-MARK"));
346 assert!(!text.contains("HEAD-MARK"));
347 assert!(text.contains("Showing the last 512 KiB"));
348 assert!(
349 page.body.len() < 530 * 1024,
350 "page is {} bytes",
351 page.body.len()
352 );
353}
354
355#[tokio::test]
356async fn log_txt_is_the_full_file_for_a_finished_job() {
357 let dir = tempfile::tempdir().expect("tempdir");
358 let fx = fixture(dir.path()).await;
359 let run = run_with(&fx, &["build"], false).await;
360 let job = job_of(&fx, &run).await;
361 let path = log_path(&fx.data_dir, &job.id);
362 std::fs::create_dir_all(path.parent().expect("log dir")).expect("log dir");
363 std::fs::write(&path, b"line one\nline two\n").expect("log");
364 finish_job(&fx).await;
365 let res = get(
366 &fx.app,
367 &format!("/alice/proj/ci/jobs/{}/log.txt", job.id),
368 Some(&cookie_for("alice-token")),
369 )
370 .await;
371 assert_eq!(res.status, 200);
372 assert_eq!(res.body, b"line one\nline two\n");
373 assert_eq!(
374 res.headers
375 .get("content-type")
376 .and_then(|v| v.to_str().ok()),
377 Some("text/plain; charset=utf-8")
378 );
379 assert_eq!(
380 res.headers
381 .get("x-content-type-options")
382 .and_then(|v| v.to_str().ok()),
383 Some("nosniff")
384 );
385}
386
387#[tokio::test]
388async fn artifact_download_sends_attachment_headers() {
389 let dir = tempfile::tempdir().expect("tempdir");
390 let fx = fixture(dir.path()).await;
391 let run = run_with(&fx, &["build"], false).await;
392 let job = job_of(&fx, &run).await;
393 let payload = b"artifact bytes";
394 let artifact = fx
395 .store
396 .add_artifact(&job.id, "report.txt", payload.len() as i64, SHA)
397 .await
398 .expect("artifact row");
399 let mut writer = fx
400 .blobs
401 .begin(&artifact_key(&artifact.id), 1024)
402 .await
403 .expect("begin");
404 writer.push(payload).await.expect("push");
405 writer.finish().await.expect("finish");
406 let res = get(
407 &fx.app,
408 &format!("/alice/proj/ci/jobs/{}/artifacts/report.txt", job.id),
409 Some(&cookie_for("alice-token")),
410 )
411 .await;
412 assert_eq!(res.status, 200);
413 assert_eq!(res.body, payload);
414 let header = |name: &str| {
415 res.headers
416 .get(name)
417 .and_then(|v| v.to_str().ok())
418 .map(str::to_owned)
419 };
420 assert_eq!(
421 header("content-disposition").as_deref(),
422 Some("attachment; filename=\"report.txt\"")
423 );
424 assert_eq!(header("content-length").as_deref(), Some("14"));
425 assert_eq!(header("x-content-type-options").as_deref(), Some("nosniff"));
426 assert_eq!(
427 header("content-type").as_deref(),
428 Some("application/octet-stream")
429 );
430}
431
432#[tokio::test]
433async fn approve_only_works_while_waiting_for_approval() {
434 let dir = tempfile::tempdir().expect("tempdir");
435 let fx = fixture(dir.path()).await;
436 let run = run_with(&fx, &["build"], true).await;
437 let run_url = format!("{RUN_BASE}/runs/{}", run.number);
438 let waiting = get(&fx.app, &run_url, Some(&cookie_for("bob-token"))).await;
439 assert!(
440 waiting.text().contains(">Approve and run<"),
441 "approve button while waiting"
442 );
443 let reader = get(&fx.app, &run_url, Some(&cookie_for("dave-token"))).await;
444 assert!(
445 !reader.text().contains(">Approve and run<"),
446 "no approve button for readers"
447 );
448
449 let approved = post(
450 &fx.app,
451 &format!("{run_url}/approve"),
452 &cookie_for("bob-token"),
453 &[],
454 )
455 .await;
456 assert!(
457 approved
458 .location()
459 .unwrap_or_default()
460 .ends_with("?notice=approved")
461 );
462 let now = fx
463 .store
464 .run_by_id(&run.id)
465 .await
466 .expect("run")
467 .expect("run");
468 assert_eq!(now.state, RunState::Queued);
469 let page = get(&fx.app, &run_url, Some(&cookie_for("bob-token"))).await;
470 assert!(
471 !page.text().contains(">Approve and run<"),
472 "no approve button after approval"
473 );
474
475 let again = post(
476 &fx.app,
477 &format!("{run_url}/approve"),
478 &cookie_for("bob-token"),
479 &[],
480 )
481 .await;
482 assert!(
483 again
484 .location()
485 .unwrap_or_default()
486 .ends_with("?notice=not-waiting")
487 );
488 let still = fx
489 .store
490 .run_by_id(&run.id)
491 .await
492 .expect("run")
493 .expect("run");
494 assert_eq!(still.state, RunState::Queued);
495}
496
497#[tokio::test]
498async fn cancel_and_rerun_post_and_redirect() {
499 let dir = tempfile::tempdir().expect("tempdir");
500 let fx = fixture(dir.path()).await;
501 let run = run_with(&fx, &["build"], false).await;
502 let run_url = format!("{RUN_BASE}/runs/{}", run.number);
503 let cancelled = post(
504 &fx.app,
505 &format!("{run_url}/cancel"),
506 &cookie_for("bob-token"),
507 &[],
508 )
509 .await;
510 assert!(
511 cancelled
512 .location()
513 .unwrap_or_default()
514 .ends_with("?notice=cancelled")
515 );
516 let now = fx
517 .store
518 .run_by_id(&run.id)
519 .await
520 .expect("run")
521 .expect("run");
522 assert_eq!(now.state, RunState::Cancelled);
523 let finished = post(
524 &fx.app,
525 &format!("{run_url}/cancel"),
526 &cookie_for("bob-token"),
527 &[],
528 )
529 .await;
530 assert!(
531 finished
532 .location()
533 .unwrap_or_default()
534 .ends_with("?notice=finished")
535 );
536
537 let rerun = post(
538 &fx.app,
539 &format!("{run_url}/rerun"),
540 &cookie_for("bob-token"),
541 &[],
542 )
543 .await;
544 assert_eq!(
545 rerun.location().as_deref(),
546 Some("/alice/proj/ci/runs/2?notice=rerun")
547 );
548 assert!(
549 fx.store
550 .run_by_number(&fx.repo.id, 2)
551 .await
552 .expect("lookup")
553 .is_some()
554 );
555}
556
557#[tokio::test]
558async fn manual_run_refuses_an_unknown_branch() {
559 let dir = tempfile::tempdir().expect("tempdir");
560 let fx = fixture(dir.path()).await;
561 let res = post(
562 &fx.app,
563 RUN_BASE,
564 &cookie_for("bob-token"),
565 &[("branch", "no-such-branch")],
566 )
567 .await;
568 assert!(
569 res.location()
570 .unwrap_or_default()
571 .ends_with("?notice=no-branch")
572 );
573 let reader = post(
574 &fx.app,
575 RUN_BASE,
576 &cookie_for("dave-token"),
577 &[("branch", "main")],
578 )
579 .await;
580 assert_eq!(reader.status, 403, "reader cannot start a manual run");
581}