forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
16.5 KiB
1//! Router-level tests for releases: permissions, drafts, tags, asset upload and download, the
2//! download counter, the size cap and deletion. Each test runs against a real bare repo.
3
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use forge_core::{Repo, Role, Store, User, 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::test_support::{app_router, create_session, test_state};
14
15const CSRF: &str = "releases-test-csrf";
16
17fn git(dir: &Path, args: &[&str]) -> String {
18 let out = std::process::Command::new("git")
19 .arg("-C")
20 .arg(dir)
21 .args(args)
22 .env("GIT_AUTHOR_NAME", "test")
23 .env("GIT_AUTHOR_EMAIL", "test@example.com")
24 .env("GIT_COMMITTER_NAME", "test")
25 .env("GIT_COMMITTER_EMAIL", "test@example.com")
26 .output()
27 .expect("git runs");
28 assert!(
29 out.status.success(),
30 "git {args:?} failed: {}",
31 String::from_utf8_lossy(&out.stderr)
32 );
33 String::from_utf8_lossy(&out.stdout).trim().to_string()
34}
35
36struct Reply {
37 status: u16,
38 location: Option<String>,
39 headers: HeaderMap,
40 body: Vec<u8>,
41}
42
43impl Reply {
44 fn text(&self) -> String {
45 String::from_utf8_lossy(&self.body).into_owned()
46 }
47}
48
49async fn send(
50 app: &Router,
51 method: &str,
52 uri: &str,
53 cookie: Option<&str>,
54 content_type: Option<&str>,
55 body: Vec<u8>,
56) -> Reply {
57 let mut builder = Request::builder().method(method).uri(uri);
58 if let Some(cookie) = cookie {
59 builder = builder.header("cookie", cookie);
60 }
61 if let Some(content_type) = content_type {
62 builder = builder.header("content-type", content_type);
63 }
64 let res = app
65 .handle(builder.body(Body::from(body)).expect("request"))
66 .await;
67 let status = res.status().as_u16();
68 let location = res
69 .headers()
70 .get("location")
71 .and_then(|v| v.to_str().ok())
72 .map(str::to_owned);
73 let headers = res.headers().clone();
74 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
75 Reply {
76 status,
77 location,
78 headers,
79 body: bytes.to_vec(),
80 }
81}
82
83fn form(pairs: &[(&str, &str)]) -> Vec<u8> {
84 form_urlencoded::Serializer::new(String::new())
85 .extend_pairs(pairs)
86 .finish()
87 .into_bytes()
88}
89
90const FORM_TYPE: &str = "application/x-www-form-urlencoded";
91
92/// One multipart body: `csrf` first, then a `file` field with the given name and bytes.
93fn upload_body(file_name: &str, data: &[u8]) -> (String, Vec<u8>) {
94 crate::pages::settings_profile::tests::multipart(&[
95 ("csrf", None, CSRF.as_bytes()),
96 ("file", Some((file_name, "application/octet-stream")), data),
97 ])
98}
99
100struct Fixture {
101 _tmp: tempfile::TempDir,
102 state: crate::state::AppState,
103 app: Router,
104 store: Store,
105 repo: Repo,
106 alice: User,
107 bare: PathBuf,
108 alice_cookie: String,
109 bob_cookie: String,
110}
111
112/// Alice owns the public repo `alice/proj` with `main` holding one commit. Bob is a Read
113/// collaborator. `max_mb` replaces the asset size limit when given.
114async fn fixture(max_mb: Option<u64>) -> Fixture {
115 let tmp = tempfile::tempdir().expect("tempdir");
116 let mut state = test_state(tmp.path()).await;
117 if let Some(mb) = max_mb {
118 let mut config = (*state.config).clone();
119 config.releases.max_asset_mb = mb;
120 state.config = Arc::new(config);
121 }
122 let store = state.store.clone();
123 let alice = store
124 .create_user("alice", None, "unused", false)
125 .await
126 .expect("alice");
127 let bob = store
128 .create_user("bob", None, "unused", false)
129 .await
130 .expect("bob");
131 let repo = store
132 .create_repo(&alice.id, "proj", "", Visibility::Public, "main")
133 .await
134 .expect("repo");
135 store
136 .set_collaborator(&repo.id, &bob.id, Role::Read)
137 .await
138 .expect("bob reads");
139 let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path");
140 init_bare(&state.git, &bare, "main")
141 .await
142 .expect("init bare");
143 let work = tmp.path().join("work");
144 git(tmp.path(), &["init", "-b", "main", "work"]);
145 std::fs::write(work.join("file.txt"), "one").expect("write");
146 git(&work, &["add", "file.txt"]);
147 git(&work, &["commit", "-m", "one"]);
148 git(&work, &["push", bare.to_str().expect("utf8"), "main"]);
149
150 create_session(&store, &alice.id, "alice-token", CSRF).await;
151 create_session(&store, &bob.id, "bob-token", CSRF).await;
152 let app = app_router(state.clone());
153 Fixture {
154 _tmp: tmp,
155 state,
156 app,
157 store,
158 repo,
159 alice,
160 bare,
161 alice_cookie: format!("__Host-{SESSION_COOKIE}=alice-token"),
162 bob_cookie: format!("__Host-{SESSION_COOKIE}=bob-token"),
163 }
164}
165
166impl Fixture {
167 fn main_tip(&self) -> String {
168 git(&self.bare, &["rev-parse", "refs/heads/main"])
169 }
170
171 fn tag_target(&self, tag: &str) -> Option<String> {
172 let out = std::process::Command::new("git")
173 .arg("-C")
174 .arg(&self.bare)
175 .args(["rev-parse", "--verify", "--quiet"])
176 .arg(format!("refs/tags/{tag}"))
177 .output()
178 .expect("git runs");
179 out.status
180 .success()
181 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
182 }
183
184 /// Creates a release row directly, bypassing the form.
185 async fn release(&self, tag: &str, draft: bool) -> forge_core::releases::Release {
186 self.store
187 .create_release(&forge_core::releases::NewRelease {
188 repo_id: &self.repo.id,
189 tag,
190 target_oid: &self.main_tip(),
191 title: &format!("Release {tag}"),
192 body: "notes",
193 draft,
194 prerelease: false,
195 author_id: &self.alice.id,
196 })
197 .await
198 .expect("release")
199 }
200
201 async fn upload(&self, release_id: &str, file: &str, data: &[u8], cookie: &str) -> Reply {
202 let (content_type, body) = upload_body(file, data);
203 send(
204 &self.app,
205 "POST",
206 &format!("/alice/proj/releases/{release_id}/assets"),
207 Some(cookie),
208 Some(&content_type),
209 body,
210 )
211 .await
212 }
213}
214
215#[tokio::test]
216async fn drafts_are_hidden_from_readers_and_visible_to_writers() {
217 let fx = fixture(None).await;
218 let published = fx.release("v1.0", false).await;
219 let draft = fx.release("v2.0", true).await;
220 let draft_asset = fx
221 .upload(&draft.id, "secret.bin", b"x", &fx.alice_cookie)
222 .await;
223 assert_eq!(draft_asset.status, 303, "writer uploads to a draft");
224
225 let list = send(
226 &fx.app,
227 "GET",
228 "/alice/proj/releases",
229 None,
230 None,
231 Vec::new(),
232 )
233 .await;
234 assert_eq!(list.status, 200);
235 assert!(list.text().contains("Release v1.0"));
236 assert!(
237 !list.text().contains("Release v2.0"),
238 "anonymous list hides drafts"
239 );
240
241 let anon_draft = send(
242 &fx.app,
243 "GET",
244 "/alice/proj/releases/tag/v2.0",
245 None,
246 None,
247 Vec::new(),
248 )
249 .await;
250 assert_eq!(anon_draft.status, 404);
251 let anon_download = send(
252 &fx.app,
253 "GET",
254 "/alice/proj/releases/download/v2.0/secret.bin",
255 None,
256 None,
257 Vec::new(),
258 )
259 .await;
260 assert_eq!(
261 anon_download.status, 404,
262 "a draft asset is not downloadable by readers"
263 );
264
265 let writer_draft = send(
266 &fx.app,
267 "GET",
268 "/alice/proj/releases/tag/v2.0",
269 Some(&fx.alice_cookie),
270 None,
271 Vec::new(),
272 )
273 .await;
274 assert_eq!(writer_draft.status, 200, "the owner sees the draft");
275 let writer_list = send(
276 &fx.app,
277 "GET",
278 "/alice/proj/releases",
279 Some(&fx.alice_cookie),
280 None,
281 Vec::new(),
282 )
283 .await;
284 assert!(writer_list.text().contains("Release v2.0"));
285 assert_eq!(published.tag, "v1.0");
286}
287
288#[tokio::test]
289async fn readers_cannot_create_edit_upload_or_delete() {
290 let fx = fixture(None).await;
291 let rel = fx.release("v1.0", false).await;
292
293 let create = send(
294 &fx.app,
295 "POST",
296 "/alice/proj/releases/new",
297 Some(&fx.bob_cookie),
298 Some(FORM_TYPE),
299 form(&[
300 ("csrf", CSRF),
301 ("tag", "v9"),
302 ("target", "main"),
303 ("title", "x"),
304 ]),
305 )
306 .await;
307 assert_ne!(create.status, 303, "a reader cannot create a release");
308 assert!(
309 fx.tag_target("v9").is_none(),
310 "no tag is created for a refused release"
311 );
312
313 let edit = send(
314 &fx.app,
315 "POST",
316 &format!("/alice/proj/releases/{}/edit", rel.id),
317 Some(&fx.bob_cookie),
318 Some(FORM_TYPE),
319 form(&[("csrf", CSRF), ("title", "hacked"), ("body", "")]),
320 )
321 .await;
322 assert_ne!(edit.status, 303);
323 assert_eq!(
324 fx.store
325 .release(&rel.id)
326 .await
327 .expect("read")
328 .expect("row")
329 .title,
330 "Release v1.0"
331 );
332
333 let upload = fx.upload(&rel.id, "a.bin", b"data", &fx.bob_cookie).await;
334 assert_ne!(upload.status, 303);
335 assert!(
336 fx.store
337 .list_release_assets(&rel.id)
338 .await
339 .expect("assets")
340 .is_empty()
341 );
342
343 let delete = send(
344 &fx.app,
345 "POST",
346 &format!("/alice/proj/releases/{}/delete", rel.id),
347 Some(&fx.bob_cookie),
348 Some(FORM_TYPE),
349 form(&[("csrf", CSRF), ("confirm", "on")]),
350 )
351 .await;
352 assert_ne!(delete.status, 303);
353 assert!(fx.store.release(&rel.id).await.expect("read").is_some());
354}
355
356#[tokio::test]
357async fn a_missing_tag_is_created_as_a_lightweight_tag_on_the_branch_head() {
358 let fx = fixture(None).await;
359 let reply = send(
360 &fx.app,
361 "POST",
362 "/alice/proj/releases/new",
363 Some(&fx.alice_cookie),
364 Some(FORM_TYPE),
365 form(&[
366 ("csrf", CSRF),
367 ("tag", "v1.0"),
368 ("target", "main"),
369 ("title", "First"),
370 ("body", "hello"),
371 ]),
372 )
373 .await;
374 assert_eq!(reply.status, 303, "body: {}", reply.text());
375 assert_eq!(
376 reply.location.as_deref(),
377 Some("/alice/proj/releases/tag/v1.0")
378 );
379 let tip = fx.main_tip();
380 assert_eq!(fx.tag_target("v1.0").as_deref(), Some(tip.as_str()));
381 let release = fx
382 .store
383 .release_by_tag(&fx.repo.id, "v1.0")
384 .await
385 .expect("read")
386 .expect("release");
387 assert_eq!(release.target_oid, tip);
388 assert!(release.published_at.is_some());
389}
390
391#[tokio::test]
392async fn an_existing_tag_is_used_as_it_is_and_never_moved() {
393 let fx = fixture(None).await;
394 git(&fx.bare, &["tag", "v0.9", "refs/heads/main"]);
395 let before = fx.tag_target("v0.9");
396 let reply = send(
397 &fx.app,
398 "POST",
399 "/alice/proj/releases/new",
400 Some(&fx.alice_cookie),
401 Some(FORM_TYPE),
402 form(&[
403 ("csrf", CSRF),
404 ("tag", "v0.9"),
405 ("target", "main"),
406 ("title", "Old"),
407 ]),
408 )
409 .await;
410 assert_eq!(reply.status, 303);
411 assert_eq!(fx.tag_target("v0.9"), before);
412}
413
414#[tokio::test]
415async fn upload_then_download_round_trips_and_counts() {
416 let fx = fixture(None).await;
417 let rel = fx.release("v1.0", false).await;
418 let payload = b"<html>not rendered</html>";
419 let reply = fx
420 .upload(&rel.id, "app.bin", payload, &fx.alice_cookie)
421 .await;
422 assert_eq!(reply.status, 303, "body: {}", reply.text());
423 assert!(
424 reply
425 .location
426 .unwrap_or_default()
427 .contains("notice=asset-uploaded")
428 );
429
430 let row = fx
431 .store
432 .release_asset(&rel.id, "app.bin")
433 .await
434 .expect("read")
435 .expect("asset row");
436 assert_eq!(row.size, payload.len() as u64);
437 let digest = {
438 use sha2::{Digest, Sha256};
439 hex::encode(Sha256::digest(payload))
440 };
441 assert_eq!(row.sha256, digest);
442
443 for expected in 1..=2u64 {
444 let got = send(
445 &fx.app,
446 "GET",
447 "/alice/proj/releases/download/v1.0/app.bin",
448 None,
449 None,
450 Vec::new(),
451 )
452 .await;
453 assert_eq!(got.status, 200);
454 assert_eq!(got.body, payload);
455 assert_eq!(
456 got.headers
457 .get("content-disposition")
458 .and_then(|v| v.to_str().ok()),
459 Some("attachment; filename=\"app.bin\"")
460 );
461 assert_eq!(
462 got.headers
463 .get("x-content-type-options")
464 .and_then(|v| v.to_str().ok()),
465 Some("nosniff")
466 );
467 let counted = fx
468 .store
469 .release_asset(&rel.id, "app.bin")
470 .await
471 .expect("read")
472 .expect("row");
473 assert_eq!(counted.download_count, expected);
474 }
475}
476
477#[tokio::test]
478async fn a_csrf_mismatch_on_upload_is_refused_and_stores_nothing() {
479 let fx = fixture(None).await;
480 let rel = fx.release("v1.0", false).await;
481 let (content_type, body) = crate::pages::settings_profile::tests::multipart(&[
482 ("csrf", None, b"wrong"),
483 ("file", Some(("a.bin", "application/octet-stream")), b"data"),
484 ]);
485 let reply = send(
486 &fx.app,
487 "POST",
488 &format!("/alice/proj/releases/{}/assets", rel.id),
489 Some(&fx.alice_cookie),
490 Some(&content_type),
491 body,
492 )
493 .await;
494 assert_ne!(reply.status, 303);
495 assert!(
496 fx.store
497 .list_release_assets(&rel.id)
498 .await
499 .expect("assets")
500 .is_empty()
501 );
502}
503
504#[tokio::test]
505async fn an_upload_over_the_size_cap_is_refused_and_leaves_no_blob() {
506 let fx = fixture(Some(1)).await;
507 let rel = fx.release("v1.0", false).await;
508 let big = vec![7u8; 1024 * 1024 + 4096];
509 let reply = fx.upload(&rel.id, "big.bin", &big, &fx.alice_cookie).await;
510 assert_eq!(reply.status, 303);
511 assert!(
512 reply
513 .location
514 .unwrap_or_default()
515 .contains("notice=asset-too-large"),
516 "the cap refuses the file"
517 );
518 assert!(
519 fx.store
520 .list_release_assets(&rel.id)
521 .await
522 .expect("assets")
523 .is_empty()
524 );
525 let assets_dir = fx.state.config.data_dir.join("blobs").join("assets");
526 let leftovers = std::fs::read_dir(&assets_dir)
527 .map(|entries| entries.count())
528 .unwrap_or(0);
529 assert_eq!(leftovers, 0, "no partial blob is left behind");
530}
531
532#[tokio::test]
533async fn deleting_a_release_keeps_the_tag_and_removes_its_assets() {
534 let fx = fixture(None).await;
535 git(&fx.bare, &["tag", "v1.0", "refs/heads/main"]);
536 let rel = fx.release("v1.0", false).await;
537 let up = fx
538 .upload(&rel.id, "gone.bin", b"bytes", &fx.alice_cookie)
539 .await;
540 assert_eq!(up.status, 303);
541 let asset = fx
542 .store
543 .release_asset(&rel.id, "gone.bin")
544 .await
545 .expect("read")
546 .expect("row");
547 let blob = crate::blobs::asset_key(&asset.id);
548 assert!(fx.state.blobs.read(&blob).await.expect("read").is_some());
549
550 let reply = send(
551 &fx.app,
552 "POST",
553 &format!("/alice/proj/releases/{}/delete", rel.id),
554 Some(&fx.alice_cookie),
555 Some(FORM_TYPE),
556 form(&[("csrf", CSRF), ("confirm", "on")]),
557 )
558 .await;
559 assert_eq!(reply.status, 303);
560 assert!(fx.store.release(&rel.id).await.expect("read").is_none());
561 assert!(fx.tag_target("v1.0").is_some(), "the git tag survives");
562 assert!(
563 fx.state.blobs.read(&blob).await.expect("read").is_none(),
564 "the blob is gone"
565 );
566 let download = send(
567 &fx.app,
568 "GET",
569 "/alice/proj/releases/download/v1.0/gone.bin",
570 None,
571 None,
572 Vec::new(),
573 )
574 .await;
575 assert_eq!(download.status, 404);
576}
577
578#[tokio::test]
579async fn the_latest_release_card_shows_on_the_repo_home() {
580 let fx = fixture(None).await;
581 fx.release("v1.0", false).await;
582 fx.release("v0.9-beta", false).await;
583 let home = send(&fx.app, "GET", "/alice/proj", None, None, Vec::new()).await;
584 assert_eq!(home.status, 200);
585 assert!(home.text().contains("Latest release"));
586 assert!(
587 home.text().contains("/alice/proj/releases"),
588 "the tabs link to releases"
589 );
590}