//! Router-level tests for releases: permissions, drafts, tags, asset upload and download, the //! download counter, the size cap and deletion. Each test runs against a real bare repo. use std::path::{Path, PathBuf}; use std::sync::Arc; use forge_core::{Repo, Role, Store, User, 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::test_support::{app_router, create_session, test_state}; const CSRF: &str = "releases-test-csrf"; fn git(dir: &Path, args: &[&str]) -> String { let out = std::process::Command::new("git") .arg("-C") .arg(dir) .args(args) .env("GIT_AUTHOR_NAME", "test") .env("GIT_AUTHOR_EMAIL", "test@example.com") .env("GIT_COMMITTER_NAME", "test") .env("GIT_COMMITTER_EMAIL", "test@example.com") .output() .expect("git runs"); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() } struct Reply { status: u16, location: Option, headers: HeaderMap, body: Vec, } impl Reply { fn text(&self) -> String { String::from_utf8_lossy(&self.body).into_owned() } } async fn send( app: &Router, method: &str, uri: &str, cookie: Option<&str>, content_type: Option<&str>, body: Vec, ) -> Reply { let mut builder = Request::builder().method(method).uri(uri); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } if let Some(content_type) = content_type { builder = builder.header("content-type", content_type); } let res = app .handle(builder.body(Body::from(body)).expect("request")) .await; let status = res.status().as_u16(); let location = res .headers() .get("location") .and_then(|v| v.to_str().ok()) .map(str::to_owned); let headers = res.headers().clone(); let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); Reply { status, location, headers, body: bytes.to_vec(), } } fn form(pairs: &[(&str, &str)]) -> Vec { form_urlencoded::Serializer::new(String::new()) .extend_pairs(pairs) .finish() .into_bytes() } const FORM_TYPE: &str = "application/x-www-form-urlencoded"; /// One multipart body: `csrf` first, then a `file` field with the given name and bytes. fn upload_body(file_name: &str, data: &[u8]) -> (String, Vec) { crate::pages::settings_profile::tests::multipart(&[ ("csrf", None, CSRF.as_bytes()), ("file", Some((file_name, "application/octet-stream")), data), ]) } struct Fixture { _tmp: tempfile::TempDir, state: crate::state::AppState, app: Router, store: Store, repo: Repo, alice: User, bare: PathBuf, alice_cookie: String, bob_cookie: String, } /// Alice owns the public repo `alice/proj` with `main` holding one commit. Bob is a Read /// collaborator. `max_mb` replaces the asset size limit when given. async fn fixture(max_mb: Option) -> Fixture { let tmp = tempfile::tempdir().expect("tempdir"); let mut state = test_state(tmp.path()).await; if let Some(mb) = max_mb { let mut config = (*state.config).clone(); config.releases.max_asset_mb = mb; state.config = Arc::new(config); } 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 repo = store .create_repo(&alice.id, "proj", "", Visibility::Public, "main") .await .expect("repo"); store .set_collaborator(&repo.id, &bob.id, Role::Read) .await .expect("bob reads"); let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); init_bare(&state.git, &bare, "main") .await .expect("init bare"); let work = tmp.path().join("work"); git(tmp.path(), &["init", "-b", "main", "work"]); std::fs::write(work.join("file.txt"), "one").expect("write"); git(&work, &["add", "file.txt"]); git(&work, &["commit", "-m", "one"]); git(&work, &["push", bare.to_str().expect("utf8"), "main"]); create_session(&store, &alice.id, "alice-token", CSRF).await; create_session(&store, &bob.id, "bob-token", CSRF).await; let app = app_router(state.clone()); Fixture { _tmp: tmp, state, app, store, repo, alice, bare, alice_cookie: format!("__Host-{SESSION_COOKIE}=alice-token"), bob_cookie: format!("__Host-{SESSION_COOKIE}=bob-token"), } } impl Fixture { fn main_tip(&self) -> String { git(&self.bare, &["rev-parse", "refs/heads/main"]) } fn tag_target(&self, tag: &str) -> Option { let out = std::process::Command::new("git") .arg("-C") .arg(&self.bare) .args(["rev-parse", "--verify", "--quiet"]) .arg(format!("refs/tags/{tag}")) .output() .expect("git runs"); out.status .success() .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) } /// Creates a release row directly, bypassing the form. async fn release(&self, tag: &str, draft: bool) -> forge_core::releases::Release { self.store .create_release(&forge_core::releases::NewRelease { repo_id: &self.repo.id, tag, target_oid: &self.main_tip(), title: &format!("Release {tag}"), body: "notes", draft, prerelease: false, author_id: &self.alice.id, }) .await .expect("release") } async fn upload(&self, release_id: &str, file: &str, data: &[u8], cookie: &str) -> Reply { let (content_type, body) = upload_body(file, data); send( &self.app, "POST", &format!("/alice/proj/releases/{release_id}/assets"), Some(cookie), Some(&content_type), body, ) .await } } #[tokio::test] async fn drafts_are_hidden_from_readers_and_visible_to_writers() { let fx = fixture(None).await; let published = fx.release("v1.0", false).await; let draft = fx.release("v2.0", true).await; let draft_asset = fx .upload(&draft.id, "secret.bin", b"x", &fx.alice_cookie) .await; assert_eq!(draft_asset.status, 303, "writer uploads to a draft"); let list = send( &fx.app, "GET", "/alice/proj/releases", None, None, Vec::new(), ) .await; assert_eq!(list.status, 200); assert!(list.text().contains("Release v1.0")); assert!( !list.text().contains("Release v2.0"), "anonymous list hides drafts" ); let anon_draft = send( &fx.app, "GET", "/alice/proj/releases/tag/v2.0", None, None, Vec::new(), ) .await; assert_eq!(anon_draft.status, 404); let anon_download = send( &fx.app, "GET", "/alice/proj/releases/download/v2.0/secret.bin", None, None, Vec::new(), ) .await; assert_eq!( anon_download.status, 404, "a draft asset is not downloadable by readers" ); let writer_draft = send( &fx.app, "GET", "/alice/proj/releases/tag/v2.0", Some(&fx.alice_cookie), None, Vec::new(), ) .await; assert_eq!(writer_draft.status, 200, "the owner sees the draft"); let writer_list = send( &fx.app, "GET", "/alice/proj/releases", Some(&fx.alice_cookie), None, Vec::new(), ) .await; assert!(writer_list.text().contains("Release v2.0")); assert_eq!(published.tag, "v1.0"); } #[tokio::test] async fn readers_cannot_create_edit_upload_or_delete() { let fx = fixture(None).await; let rel = fx.release("v1.0", false).await; let create = send( &fx.app, "POST", "/alice/proj/releases/new", Some(&fx.bob_cookie), Some(FORM_TYPE), form(&[ ("csrf", CSRF), ("tag", "v9"), ("target", "main"), ("title", "x"), ]), ) .await; assert_ne!(create.status, 303, "a reader cannot create a release"); assert!( fx.tag_target("v9").is_none(), "no tag is created for a refused release" ); let edit = send( &fx.app, "POST", &format!("/alice/proj/releases/{}/edit", rel.id), Some(&fx.bob_cookie), Some(FORM_TYPE), form(&[("csrf", CSRF), ("title", "hacked"), ("body", "")]), ) .await; assert_ne!(edit.status, 303); assert_eq!( fx.store .release(&rel.id) .await .expect("read") .expect("row") .title, "Release v1.0" ); let upload = fx.upload(&rel.id, "a.bin", b"data", &fx.bob_cookie).await; assert_ne!(upload.status, 303); assert!( fx.store .list_release_assets(&rel.id) .await .expect("assets") .is_empty() ); let delete = send( &fx.app, "POST", &format!("/alice/proj/releases/{}/delete", rel.id), Some(&fx.bob_cookie), Some(FORM_TYPE), form(&[("csrf", CSRF), ("confirm", "on")]), ) .await; assert_ne!(delete.status, 303); assert!(fx.store.release(&rel.id).await.expect("read").is_some()); } #[tokio::test] async fn a_missing_tag_is_created_as_a_lightweight_tag_on_the_branch_head() { let fx = fixture(None).await; let reply = send( &fx.app, "POST", "/alice/proj/releases/new", Some(&fx.alice_cookie), Some(FORM_TYPE), form(&[ ("csrf", CSRF), ("tag", "v1.0"), ("target", "main"), ("title", "First"), ("body", "hello"), ]), ) .await; assert_eq!(reply.status, 303, "body: {}", reply.text()); assert_eq!( reply.location.as_deref(), Some("/alice/proj/releases/tag/v1.0") ); let tip = fx.main_tip(); assert_eq!(fx.tag_target("v1.0").as_deref(), Some(tip.as_str())); let release = fx .store .release_by_tag(&fx.repo.id, "v1.0") .await .expect("read") .expect("release"); assert_eq!(release.target_oid, tip); assert!(release.published_at.is_some()); } #[tokio::test] async fn an_existing_tag_is_used_as_it_is_and_never_moved() { let fx = fixture(None).await; git(&fx.bare, &["tag", "v0.9", "refs/heads/main"]); let before = fx.tag_target("v0.9"); let reply = send( &fx.app, "POST", "/alice/proj/releases/new", Some(&fx.alice_cookie), Some(FORM_TYPE), form(&[ ("csrf", CSRF), ("tag", "v0.9"), ("target", "main"), ("title", "Old"), ]), ) .await; assert_eq!(reply.status, 303); assert_eq!(fx.tag_target("v0.9"), before); } #[tokio::test] async fn upload_then_download_round_trips_and_counts() { let fx = fixture(None).await; let rel = fx.release("v1.0", false).await; let payload = b"not rendered"; let reply = fx .upload(&rel.id, "app.bin", payload, &fx.alice_cookie) .await; assert_eq!(reply.status, 303, "body: {}", reply.text()); assert!( reply .location .unwrap_or_default() .contains("notice=asset-uploaded") ); let row = fx .store .release_asset(&rel.id, "app.bin") .await .expect("read") .expect("asset row"); assert_eq!(row.size, payload.len() as u64); let digest = { use sha2::{Digest, Sha256}; hex::encode(Sha256::digest(payload)) }; assert_eq!(row.sha256, digest); for expected in 1..=2u64 { let got = send( &fx.app, "GET", "/alice/proj/releases/download/v1.0/app.bin", None, None, Vec::new(), ) .await; assert_eq!(got.status, 200); assert_eq!(got.body, payload); assert_eq!( got.headers .get("content-disposition") .and_then(|v| v.to_str().ok()), Some("attachment; filename=\"app.bin\"") ); assert_eq!( got.headers .get("x-content-type-options") .and_then(|v| v.to_str().ok()), Some("nosniff") ); let counted = fx .store .release_asset(&rel.id, "app.bin") .await .expect("read") .expect("row"); assert_eq!(counted.download_count, expected); } } #[tokio::test] async fn a_csrf_mismatch_on_upload_is_refused_and_stores_nothing() { let fx = fixture(None).await; let rel = fx.release("v1.0", false).await; let (content_type, body) = crate::pages::settings_profile::tests::multipart(&[ ("csrf", None, b"wrong"), ("file", Some(("a.bin", "application/octet-stream")), b"data"), ]); let reply = send( &fx.app, "POST", &format!("/alice/proj/releases/{}/assets", rel.id), Some(&fx.alice_cookie), Some(&content_type), body, ) .await; assert_ne!(reply.status, 303); assert!( fx.store .list_release_assets(&rel.id) .await .expect("assets") .is_empty() ); } #[tokio::test] async fn an_upload_over_the_size_cap_is_refused_and_leaves_no_blob() { let fx = fixture(Some(1)).await; let rel = fx.release("v1.0", false).await; let big = vec![7u8; 1024 * 1024 + 4096]; let reply = fx.upload(&rel.id, "big.bin", &big, &fx.alice_cookie).await; assert_eq!(reply.status, 303); assert!( reply .location .unwrap_or_default() .contains("notice=asset-too-large"), "the cap refuses the file" ); assert!( fx.store .list_release_assets(&rel.id) .await .expect("assets") .is_empty() ); let assets_dir = fx.state.config.data_dir.join("blobs").join("assets"); let leftovers = std::fs::read_dir(&assets_dir) .map(|entries| entries.count()) .unwrap_or(0); assert_eq!(leftovers, 0, "no partial blob is left behind"); } #[tokio::test] async fn deleting_a_release_keeps_the_tag_and_removes_its_assets() { let fx = fixture(None).await; git(&fx.bare, &["tag", "v1.0", "refs/heads/main"]); let rel = fx.release("v1.0", false).await; let up = fx .upload(&rel.id, "gone.bin", b"bytes", &fx.alice_cookie) .await; assert_eq!(up.status, 303); let asset = fx .store .release_asset(&rel.id, "gone.bin") .await .expect("read") .expect("row"); let blob = crate::blobs::asset_key(&asset.id); assert!(fx.state.blobs.read(&blob).await.expect("read").is_some()); let reply = send( &fx.app, "POST", &format!("/alice/proj/releases/{}/delete", rel.id), Some(&fx.alice_cookie), Some(FORM_TYPE), form(&[("csrf", CSRF), ("confirm", "on")]), ) .await; assert_eq!(reply.status, 303); assert!(fx.store.release(&rel.id).await.expect("read").is_none()); assert!(fx.tag_target("v1.0").is_some(), "the git tag survives"); assert!( fx.state.blobs.read(&blob).await.expect("read").is_none(), "the blob is gone" ); let download = send( &fx.app, "GET", "/alice/proj/releases/download/v1.0/gone.bin", None, None, Vec::new(), ) .await; assert_eq!(download.status, 404); } #[tokio::test] async fn the_latest_release_card_shows_on_the_repo_home() { let fx = fixture(None).await; fx.release("v1.0", false).await; fx.release("v0.9-beta", false).await; let home = send(&fx.app, "GET", "/alice/proj", None, None, Vec::new()).await; assert_eq!(home.status, 200); assert!(home.text().contains("Latest release")); assert!( home.text().contains("/alice/proj/releases"), "the tabs link to releases" ); }