//! Route tests for the LFS API, driven through the public router. use std::path::PathBuf; use std::sync::Arc; use base64::prelude::{BASE64_STANDARD, Engine}; use forge_core::lfs_token::{self, LfsOp}; use forge_core::mirrors::{MirrorDirection, NewMirror}; use forge_core::token::{generate_token, hash_token}; use forge_core::{Store, Visibility}; use sha2::{Digest, Sha256}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::test_support::{KEY, app_router, test_state}; const OWNER: &str = "alice"; const REPO: &str = "demo"; struct Fixture { router: Router, store: Store, repo_id: String, token: String, data: PathBuf, _dir: tempfile::TempDir, } /// One user with a PAT, and one repo of the given visibility. async fn fixture(visibility: Visibility) -> Fixture { let dir = tempfile::tempdir().unwrap(); let data = dir.path().to_path_buf(); let state = test_state(&data).await; let store = state.store.clone(); let router = app_router(state); let alice = store .create_user(OWNER, None, "not-a-real-hash", false) .await .unwrap(); let token = generate_token(); store .create_token(&alice.id, "test", &hash_token(&token), &[], None) .await .unwrap(); let repo = store .create_repo(&alice.id, REPO, "", visibility, "main") .await .unwrap(); Fixture { router, store, repo_id: repo.id, token, data, _dir: dir, } } impl Fixture { /// Rebuilds the router with a different LFS size limit, in MiB. async fn with_max_mb(&mut self, max_object_mb: u64) { let mut state = test_state(&self.data).await; state.store = self.store.clone(); let mut config = (*state.config).clone(); config.lfs.max_object_mb = max_object_mb; state.config = Arc::new(config); self.router = app_router(state); } } fn basic(user: &str, token: &str) -> String { format!( "Basic {}", BASE64_STANDARD.encode(format!("{user}:{token}")) ) } fn oid_of(bytes: &[u8]) -> String { hex::encode(Sha256::digest(bytes)) } struct Reply { status: u16, headers: http::HeaderMap, body: Vec, } impl Reply { fn json(&self) -> serde_json::Value { serde_json::from_slice(&self.body).unwrap() } } async fn send( router: &Router, method: &str, uri: &str, headers: &[(&str, &str)], body: Vec, ) -> Reply { let mut builder = Request::builder().method(method).uri(uri); for (name, value) in headers { builder = builder.header(*name, *value); } 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(); Reply { status, headers, body, } } fn objects_uri(suffix: &str) -> String { format!("/{OWNER}/{REPO}.git/info/lfs/objects{suffix}") } fn batch_body(operation: &str, objects: &[(String, u64)]) -> Vec { let objects: Vec<_> = objects .iter() .map(|(oid, size)| serde_json::json!({ "oid": oid, "size": size })) .collect(); serde_json::to_vec(&serde_json::json!({ "operation": operation, "transfers": ["basic"], "objects": objects, })) .unwrap() } /// Uploads `bytes` with the PAT and asserts it was stored. async fn upload_with_pat(fx: &Fixture, bytes: &[u8]) -> String { let oid = oid_of(bytes); let auth = basic(OWNER, &fx.token); let reply = send( &fx.router, "PUT", &objects_uri(&format!("/{oid}")), &[("authorization", &auth)], bytes.to_vec(), ) .await; assert_eq!( reply.status, 200, "{}", String::from_utf8_lossy(&reply.body) ); oid } #[tokio::test] async fn batch_download_returns_action_for_stored_object() { let fx = fixture(Visibility::Public).await; let oid = upload_with_pat(&fx, b"hello lfs\n").await; let missing = oid_of(b"never stored"); let reply = send( &fx.router, "POST", &objects_uri("/batch"), &[("content-type", "application/vnd.git-lfs+json")], batch_body("download", &[(oid.clone(), 10), (missing.clone(), 5)]), ) .await; assert_eq!(reply.status, 200); assert_eq!( reply.headers["content-type"], "application/vnd.git-lfs+json" ); let body = reply.json(); assert_eq!(body["transfer"], "basic"); let found = &body["objects"][0]; assert_eq!(found["oid"], oid); assert_eq!(found["size"], 10); let action = &found["actions"]["download"]; assert_eq!( action["href"], format!("https://forge.test/{OWNER}/{REPO}.git/info/lfs/objects/{oid}") ); let header = action["header"]["Authorization"].as_str().unwrap(); assert!(header.starts_with("RemoteAuth ")); assert!(lfs_token::verify( &KEY, header.trim_start_matches("RemoteAuth "), &fx.repo_id, LfsOp::Download, now(), )); assert_eq!(body["objects"][1]["error"]["code"], 404); } #[tokio::test] async fn batch_accepts_the_path_without_dot_git() { let fx = fixture(Visibility::Public).await; let oid = upload_with_pat(&fx, b"bare path\n").await; let reply = send( &fx.router, "POST", &format!("/{OWNER}/{REPO}/info/lfs/objects/batch"), &[], batch_body("download", &[(oid.clone(), 9)]), ) .await; assert_eq!(reply.status, 200); assert!(reply.json()["objects"][0]["actions"]["download"].is_object()); } #[tokio::test] async fn public_reads_need_no_credentials_and_private_ones_get_the_challenge() { let public = fixture(Visibility::Public).await; let oid = upload_with_pat(&public, b"public\n").await; let get = send( &public.router, "GET", &objects_uri(&format!("/{oid}")), &[], Vec::new(), ) .await; assert_eq!(get.status, 200); assert_eq!(get.body, b"public\n"); let private = fixture(Visibility::Private).await; let oid = upload_with_pat(&private, b"secret\n").await; let batch = send( &private.router, "POST", &objects_uri("/batch"), &[], batch_body("download", &[(oid.clone(), 7)]), ) .await; assert_eq!(batch.status, 401); assert_eq!(batch.headers["lfs-authenticate"], "Basic realm=\"forge\""); let get = send( &private.router, "GET", &objects_uri(&format!("/{oid}")), &[], Vec::new(), ) .await; assert_eq!(get.status, 401); } #[tokio::test] async fn upload_batch_needs_a_valid_pat() { let fx = fixture(Visibility::Public).await; let oid = oid_of(b"data"); let body = batch_body("upload", &[(oid.clone(), 4)]); let anonymous = send( &fx.router, "POST", &objects_uri("/batch"), &[], body.clone(), ) .await; assert_eq!(anonymous.status, 401); assert_eq!( anonymous.headers["lfs-authenticate"], "Basic realm=\"forge\"" ); let wrong = basic(OWNER, "not-a-token"); let bad = send( &fx.router, "POST", &objects_uri("/batch"), &[("authorization", &wrong)], body.clone(), ) .await; assert_eq!(bad.status, 401); let auth = basic(OWNER, &fx.token); let good = send( &fx.router, "POST", &objects_uri("/batch"), &[("authorization", &auth)], body, ) .await; assert_eq!(good.status, 200); let actions = &good.json()["objects"][0]["actions"]; assert!(actions["upload"]["href"].as_str().unwrap().ends_with(&oid)); assert!( actions["verify"]["href"] .as_str() .unwrap() .ends_with("/objects/verify") ); } #[tokio::test] async fn upload_batch_skips_objects_already_linked() { let fx = fixture(Visibility::Public).await; let oid = upload_with_pat(&fx, b"already here\n").await; let auth = basic(OWNER, &fx.token); let reply = send( &fx.router, "POST", &objects_uri("/batch"), &[("authorization", &auth)], batch_body("upload", &[(oid, 13)]), ) .await; assert_eq!(reply.status, 200); assert!(reply.json()["objects"][0].get("actions").is_none()); } #[tokio::test] async fn oid_and_size_are_validated() { let mut fx = fixture(Visibility::Public).await; let auth = basic(OWNER, &fx.token); let upper = oid_of(b"x").to_uppercase(); let reply = send( &fx.router, "POST", &objects_uri("/batch"), &[("authorization", &auth)], batch_body("upload", &[(upper, 1)]), ) .await; assert_eq!(reply.status, 422); let reply = send( &fx.router, "POST", &objects_uri("/batch"), &[("authorization", &auth)], batch_body("upload", &[("abc".into(), 1)]), ) .await; assert_eq!(reply.status, 422); let reply = send( &fx.router, "PUT", &objects_uri("/not-an-oid"), &[("authorization", &auth)], b"x".to_vec(), ) .await; assert_eq!(reply.status, 422); fx.with_max_mb(1).await; let too_big = 1024 * 1024 + 1; let reply = send( &fx.router, "POST", &objects_uri("/batch"), &[("authorization", &auth)], batch_body("upload", &[(oid_of(b"big"), too_big)]), ) .await; assert_eq!(reply.status, 422); let reply = send( &fx.router, "POST", &objects_uri("/batch"), &[("authorization", &auth)], br#"{"operation":"sideways","objects":[]}"#.to_vec(), ) .await; assert_eq!(reply.status, 422); } #[tokio::test] async fn put_rejects_a_hash_mismatch_and_stores_nothing() { let fx = fixture(Visibility::Public).await; let auth = basic(OWNER, &fx.token); let claimed = oid_of(b"the promised bytes"); let reply = send( &fx.router, "PUT", &objects_uri(&format!("/{claimed}")), &[("authorization", &auth)], b"different bytes".to_vec(), ) .await; assert_eq!(reply.status, 422); assert_eq!( fx.store .lfs_object_size(&fx.repo_id, &claimed) .await .unwrap(), None ); assert!(!object_file(&fx.data, &claimed).exists()); let temp_dir = fx.data.join("lfs").join("tmp"); let leftovers = std::fs::read_dir(&temp_dir).map(|d| d.count()).unwrap_or(0); assert_eq!(leftovers, 0, "a failed upload leaves no temp file"); } #[tokio::test] async fn put_then_get_round_trips_the_bytes() { let fx = fixture(Visibility::Private).await; let data = vec![0xAB_u8; 300_000]; let oid = upload_with_pat(&fx, &data).await; assert!(object_file(&fx.data, &oid).exists()); assert_eq!( fx.store.lfs_object_size(&fx.repo_id, &oid).await.unwrap(), Some(300_000) ); let auth = basic(OWNER, &fx.token); let got = send( &fx.router, "GET", &objects_uri(&format!("/{oid}")), &[("authorization", &auth)], Vec::new(), ) .await; assert_eq!(got.status, 200); assert_eq!(got.headers["content-length"], "300000"); assert_eq!(got.body, data); } #[tokio::test] async fn remote_auth_tokens_are_scoped_to_their_operation() { let fx = fixture(Visibility::Private).await; let body = b"token upload\n"; let oid = oid_of(body); let expires = now() + 600; let upload = lfs_token::issue(&KEY, &fx.repo_id, LfsOp::Upload, expires); let download = lfs_token::issue(&KEY, &fx.repo_id, LfsOp::Download, expires); let upload_auth = format!("RemoteAuth {upload}"); let download_auth = format!("RemoteAuth {download}"); let refused = send( &fx.router, "PUT", &objects_uri(&format!("/{oid}")), &[("authorization", &download_auth)], body.to_vec(), ) .await; assert_eq!(refused.status, 401); let stored = send( &fx.router, "PUT", &objects_uri(&format!("/{oid}")), &[("authorization", &upload_auth)], body.to_vec(), ) .await; assert_eq!(stored.status, 200); let got = send( &fx.router, "GET", &objects_uri(&format!("/{oid}")), &[("authorization", &download_auth)], Vec::new(), ) .await; assert_eq!(got.status, 200); assert_eq!(got.body, body); let wrong_repo = lfs_token::issue(&KEY, "some-other-repo", LfsOp::Download, expires); let got = send( &fx.router, "GET", &objects_uri(&format!("/{oid}")), &[("authorization", &format!("RemoteAuth {wrong_repo}"))], Vec::new(), ) .await; assert_eq!(got.status, 401); } #[tokio::test] async fn verify_checks_presence_and_size() { let fx = fixture(Visibility::Public).await; let oid = upload_with_pat(&fx, b"verify me\n").await; let auth = basic(OWNER, &fx.token); let verify = |oid: String, size: u64| { let body = serde_json::to_vec(&serde_json::json!({ "oid": oid, "size": size })).unwrap(); (body, auth.clone()) }; let (body, auth_value) = verify(oid.clone(), 10); let ok = send( &fx.router, "POST", &objects_uri("/verify"), &[("authorization", &auth_value)], body, ) .await; assert_eq!(ok.status, 200); let (body, auth_value) = verify(oid.clone(), 11); let mismatch = send( &fx.router, "POST", &objects_uri("/verify"), &[("authorization", &auth_value)], body, ) .await; assert_eq!(mismatch.status, 422); let (body, auth_value) = verify(oid_of(b"absent"), 6); let absent = send( &fx.router, "POST", &objects_uri("/verify"), &[("authorization", &auth_value)], body, ) .await; assert_eq!(absent.status, 404); } #[tokio::test] async fn archived_and_pull_mirror_repos_refuse_uploads() { let fx = fixture(Visibility::Public).await; let oid = oid_of(b"frozen\n"); let auth = basic(OWNER, &fx.token); let batch = batch_body("upload", &[(oid.clone(), 7)]); fx.store.set_repo_archived(&fx.repo_id, true).await.unwrap(); let reply = send( &fx.router, "POST", &objects_uri("/batch"), &[("authorization", &auth)], batch.clone(), ) .await; assert_eq!(reply.status, 403); let reply = send( &fx.router, "PUT", &objects_uri(&format!("/{oid}")), &[("authorization", &auth)], b"frozen\n".to_vec(), ) .await; assert_eq!(reply.status, 403); fx.store .set_repo_archived(&fx.repo_id, false) .await .unwrap(); fx.store .create_mirror(&NewMirror { repo_id: &fx.repo_id, direction: MirrorDirection::Pull, url: "https://example.com/upstream.git", username: "", secret: None, interval_minutes: 0, }) .await .unwrap(); let reply = send( &fx.router, "PUT", &objects_uri(&format!("/{oid}")), &[("authorization", &auth)], b"frozen\n".to_vec(), ) .await; assert_eq!(reply.status, 403); let reply = send( &fx.router, "POST", &objects_uri("/batch"), &[("authorization", &auth)], batch, ) .await; assert_eq!(reply.status, 403); } #[tokio::test] async fn lock_paths_are_not_implemented() { let fx = fixture(Visibility::Public).await; let auth = basic(OWNER, &fx.token); for (method, suffix) in [ ("GET", "/info/lfs/locks"), ("POST", "/info/lfs/locks"), ("POST", "/info/lfs/locks/verify"), ("POST", "/info/lfs/locks/abc/unlock"), ] { let uri = format!("/{OWNER}/{REPO}.git{suffix}"); let reply = send( &fx.router, method, &uri, &[("authorization", &auth)], Vec::new(), ) .await; assert_eq!(reply.status, 404, "{method} {uri}"); } } fn object_file(data: &std::path::Path, oid: &str) -> PathBuf { crate::lfs::object_path(data, oid) } fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) .unwrap_or(0) }