forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
16.4 KiB
1//! Route tests for the LFS API, driven through the public router.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use base64::prelude::{BASE64_STANDARD, Engine};
7use forge_core::lfs_token::{self, LfsOp};
8use forge_core::mirrors::{MirrorDirection, NewMirror};
9use forge_core::token::{generate_token, hash_token};
10use forge_core::{Store, Visibility};
11use sha2::{Digest, Sha256};
12use topcoat::router::request::Request;
13use topcoat::router::{Body, Router, to_bytes};
14
15use crate::test_support::{KEY, app_router, test_state};
16
17const OWNER: &str = "alice";
18const REPO: &str = "demo";
19
20struct Fixture {
21 router: Router,
22 store: Store,
23 repo_id: String,
24 token: String,
25 data: PathBuf,
26 _dir: tempfile::TempDir,
27}
28
29/// One user with a PAT, and one repo of the given visibility.
30async fn fixture(visibility: Visibility) -> Fixture {
31 let dir = tempfile::tempdir().unwrap();
32 let data = dir.path().to_path_buf();
33 let state = test_state(&data).await;
34 let store = state.store.clone();
35 let router = app_router(state);
36
37 let alice = store
38 .create_user(OWNER, None, "not-a-real-hash", false)
39 .await
40 .unwrap();
41 let token = generate_token();
42 store
43 .create_token(&alice.id, "test", &hash_token(&token), &[], None)
44 .await
45 .unwrap();
46 let repo = store
47 .create_repo(&alice.id, REPO, "", visibility, "main")
48 .await
49 .unwrap();
50 Fixture {
51 router,
52 store,
53 repo_id: repo.id,
54 token,
55 data,
56 _dir: dir,
57 }
58}
59
60impl Fixture {
61 /// Rebuilds the router with a different LFS size limit, in MiB.
62 async fn with_max_mb(&mut self, max_object_mb: u64) {
63 let mut state = test_state(&self.data).await;
64 state.store = self.store.clone();
65 let mut config = (*state.config).clone();
66 config.lfs.max_object_mb = max_object_mb;
67 state.config = Arc::new(config);
68 self.router = app_router(state);
69 }
70}
71
72fn basic(user: &str, token: &str) -> String {
73 format!(
74 "Basic {}",
75 BASE64_STANDARD.encode(format!("{user}:{token}"))
76 )
77}
78
79fn oid_of(bytes: &[u8]) -> String {
80 hex::encode(Sha256::digest(bytes))
81}
82
83struct Reply {
84 status: u16,
85 headers: http::HeaderMap,
86 body: Vec<u8>,
87}
88
89impl Reply {
90 fn json(&self) -> serde_json::Value {
91 serde_json::from_slice(&self.body).unwrap()
92 }
93}
94
95async fn send(
96 router: &Router,
97 method: &str,
98 uri: &str,
99 headers: &[(&str, &str)],
100 body: Vec<u8>,
101) -> Reply {
102 let mut builder = Request::builder().method(method).uri(uri);
103 for (name, value) in headers {
104 builder = builder.header(*name, *value);
105 }
106 let req = builder.body(Body::from(body)).unwrap();
107 let res = router.handle(req).await;
108 let status = res.status().as_u16();
109 let headers = res.headers().clone();
110 let body = to_bytes(res.into_body(), usize::MAX)
111 .await
112 .unwrap()
113 .to_vec();
114 Reply {
115 status,
116 headers,
117 body,
118 }
119}
120
121fn objects_uri(suffix: &str) -> String {
122 format!("/{OWNER}/{REPO}.git/info/lfs/objects{suffix}")
123}
124
125fn batch_body(operation: &str, objects: &[(String, u64)]) -> Vec<u8> {
126 let objects: Vec<_> = objects
127 .iter()
128 .map(|(oid, size)| serde_json::json!({ "oid": oid, "size": size }))
129 .collect();
130 serde_json::to_vec(&serde_json::json!({
131 "operation": operation,
132 "transfers": ["basic"],
133 "objects": objects,
134 }))
135 .unwrap()
136}
137
138/// Uploads `bytes` with the PAT and asserts it was stored.
139async fn upload_with_pat(fx: &Fixture, bytes: &[u8]) -> String {
140 let oid = oid_of(bytes);
141 let auth = basic(OWNER, &fx.token);
142 let reply = send(
143 &fx.router,
144 "PUT",
145 &objects_uri(&format!("/{oid}")),
146 &[("authorization", &auth)],
147 bytes.to_vec(),
148 )
149 .await;
150 assert_eq!(
151 reply.status,
152 200,
153 "{}",
154 String::from_utf8_lossy(&reply.body)
155 );
156 oid
157}
158
159#[tokio::test]
160async fn batch_download_returns_action_for_stored_object() {
161 let fx = fixture(Visibility::Public).await;
162 let oid = upload_with_pat(&fx, b"hello lfs\n").await;
163 let missing = oid_of(b"never stored");
164
165 let reply = send(
166 &fx.router,
167 "POST",
168 &objects_uri("/batch"),
169 &[("content-type", "application/vnd.git-lfs+json")],
170 batch_body("download", &[(oid.clone(), 10), (missing.clone(), 5)]),
171 )
172 .await;
173
174 assert_eq!(reply.status, 200);
175 assert_eq!(
176 reply.headers["content-type"],
177 "application/vnd.git-lfs+json"
178 );
179 let body = reply.json();
180 assert_eq!(body["transfer"], "basic");
181 let found = &body["objects"][0];
182 assert_eq!(found["oid"], oid);
183 assert_eq!(found["size"], 10);
184 let action = &found["actions"]["download"];
185 assert_eq!(
186 action["href"],
187 format!("https://forge.test/{OWNER}/{REPO}.git/info/lfs/objects/{oid}")
188 );
189 let header = action["header"]["Authorization"].as_str().unwrap();
190 assert!(header.starts_with("RemoteAuth "));
191 assert!(lfs_token::verify(
192 &KEY,
193 header.trim_start_matches("RemoteAuth "),
194 &fx.repo_id,
195 LfsOp::Download,
196 now(),
197 ));
198 assert_eq!(body["objects"][1]["error"]["code"], 404);
199}
200
201#[tokio::test]
202async fn batch_accepts_the_path_without_dot_git() {
203 let fx = fixture(Visibility::Public).await;
204 let oid = upload_with_pat(&fx, b"bare path\n").await;
205 let reply = send(
206 &fx.router,
207 "POST",
208 &format!("/{OWNER}/{REPO}/info/lfs/objects/batch"),
209 &[],
210 batch_body("download", &[(oid.clone(), 9)]),
211 )
212 .await;
213 assert_eq!(reply.status, 200);
214 assert!(reply.json()["objects"][0]["actions"]["download"].is_object());
215}
216
217#[tokio::test]
218async fn public_reads_need_no_credentials_and_private_ones_get_the_challenge() {
219 let public = fixture(Visibility::Public).await;
220 let oid = upload_with_pat(&public, b"public\n").await;
221 let get = send(
222 &public.router,
223 "GET",
224 &objects_uri(&format!("/{oid}")),
225 &[],
226 Vec::new(),
227 )
228 .await;
229 assert_eq!(get.status, 200);
230 assert_eq!(get.body, b"public\n");
231
232 let private = fixture(Visibility::Private).await;
233 let oid = upload_with_pat(&private, b"secret\n").await;
234 let batch = send(
235 &private.router,
236 "POST",
237 &objects_uri("/batch"),
238 &[],
239 batch_body("download", &[(oid.clone(), 7)]),
240 )
241 .await;
242 assert_eq!(batch.status, 401);
243 assert_eq!(batch.headers["lfs-authenticate"], "Basic realm=\"forge\"");
244 let get = send(
245 &private.router,
246 "GET",
247 &objects_uri(&format!("/{oid}")),
248 &[],
249 Vec::new(),
250 )
251 .await;
252 assert_eq!(get.status, 401);
253}
254
255#[tokio::test]
256async fn upload_batch_needs_a_valid_pat() {
257 let fx = fixture(Visibility::Public).await;
258 let oid = oid_of(b"data");
259 let body = batch_body("upload", &[(oid.clone(), 4)]);
260
261 let anonymous = send(
262 &fx.router,
263 "POST",
264 &objects_uri("/batch"),
265 &[],
266 body.clone(),
267 )
268 .await;
269 assert_eq!(anonymous.status, 401);
270 assert_eq!(
271 anonymous.headers["lfs-authenticate"],
272 "Basic realm=\"forge\""
273 );
274
275 let wrong = basic(OWNER, "not-a-token");
276 let bad = send(
277 &fx.router,
278 "POST",
279 &objects_uri("/batch"),
280 &[("authorization", &wrong)],
281 body.clone(),
282 )
283 .await;
284 assert_eq!(bad.status, 401);
285
286 let auth = basic(OWNER, &fx.token);
287 let good = send(
288 &fx.router,
289 "POST",
290 &objects_uri("/batch"),
291 &[("authorization", &auth)],
292 body,
293 )
294 .await;
295 assert_eq!(good.status, 200);
296 let actions = &good.json()["objects"][0]["actions"];
297 assert!(actions["upload"]["href"].as_str().unwrap().ends_with(&oid));
298 assert!(
299 actions["verify"]["href"]
300 .as_str()
301 .unwrap()
302 .ends_with("/objects/verify")
303 );
304}
305
306#[tokio::test]
307async fn upload_batch_skips_objects_already_linked() {
308 let fx = fixture(Visibility::Public).await;
309 let oid = upload_with_pat(&fx, b"already here\n").await;
310 let auth = basic(OWNER, &fx.token);
311 let reply = send(
312 &fx.router,
313 "POST",
314 &objects_uri("/batch"),
315 &[("authorization", &auth)],
316 batch_body("upload", &[(oid, 13)]),
317 )
318 .await;
319 assert_eq!(reply.status, 200);
320 assert!(reply.json()["objects"][0].get("actions").is_none());
321}
322
323#[tokio::test]
324async fn oid_and_size_are_validated() {
325 let mut fx = fixture(Visibility::Public).await;
326 let auth = basic(OWNER, &fx.token);
327 let upper = oid_of(b"x").to_uppercase();
328 let reply = send(
329 &fx.router,
330 "POST",
331 &objects_uri("/batch"),
332 &[("authorization", &auth)],
333 batch_body("upload", &[(upper, 1)]),
334 )
335 .await;
336 assert_eq!(reply.status, 422);
337
338 let reply = send(
339 &fx.router,
340 "POST",
341 &objects_uri("/batch"),
342 &[("authorization", &auth)],
343 batch_body("upload", &[("abc".into(), 1)]),
344 )
345 .await;
346 assert_eq!(reply.status, 422);
347
348 let reply = send(
349 &fx.router,
350 "PUT",
351 &objects_uri("/not-an-oid"),
352 &[("authorization", &auth)],
353 b"x".to_vec(),
354 )
355 .await;
356 assert_eq!(reply.status, 422);
357
358 fx.with_max_mb(1).await;
359 let too_big = 1024 * 1024 + 1;
360 let reply = send(
361 &fx.router,
362 "POST",
363 &objects_uri("/batch"),
364 &[("authorization", &auth)],
365 batch_body("upload", &[(oid_of(b"big"), too_big)]),
366 )
367 .await;
368 assert_eq!(reply.status, 422);
369
370 let reply = send(
371 &fx.router,
372 "POST",
373 &objects_uri("/batch"),
374 &[("authorization", &auth)],
375 br#"{"operation":"sideways","objects":[]}"#.to_vec(),
376 )
377 .await;
378 assert_eq!(reply.status, 422);
379}
380
381#[tokio::test]
382async fn put_rejects_a_hash_mismatch_and_stores_nothing() {
383 let fx = fixture(Visibility::Public).await;
384 let auth = basic(OWNER, &fx.token);
385 let claimed = oid_of(b"the promised bytes");
386 let reply = send(
387 &fx.router,
388 "PUT",
389 &objects_uri(&format!("/{claimed}")),
390 &[("authorization", &auth)],
391 b"different bytes".to_vec(),
392 )
393 .await;
394 assert_eq!(reply.status, 422);
395 assert_eq!(
396 fx.store
397 .lfs_object_size(&fx.repo_id, &claimed)
398 .await
399 .unwrap(),
400 None
401 );
402 assert!(!object_file(&fx.data, &claimed).exists());
403 let temp_dir = fx.data.join("lfs").join("tmp");
404 let leftovers = std::fs::read_dir(&temp_dir).map(|d| d.count()).unwrap_or(0);
405 assert_eq!(leftovers, 0, "a failed upload leaves no temp file");
406}
407
408#[tokio::test]
409async fn put_then_get_round_trips_the_bytes() {
410 let fx = fixture(Visibility::Private).await;
411 let data = vec![0xAB_u8; 300_000];
412 let oid = upload_with_pat(&fx, &data).await;
413
414 assert!(object_file(&fx.data, &oid).exists());
415 assert_eq!(
416 fx.store.lfs_object_size(&fx.repo_id, &oid).await.unwrap(),
417 Some(300_000)
418 );
419
420 let auth = basic(OWNER, &fx.token);
421 let got = send(
422 &fx.router,
423 "GET",
424 &objects_uri(&format!("/{oid}")),
425 &[("authorization", &auth)],
426 Vec::new(),
427 )
428 .await;
429 assert_eq!(got.status, 200);
430 assert_eq!(got.headers["content-length"], "300000");
431 assert_eq!(got.body, data);
432}
433
434#[tokio::test]
435async fn remote_auth_tokens_are_scoped_to_their_operation() {
436 let fx = fixture(Visibility::Private).await;
437 let body = b"token upload\n";
438 let oid = oid_of(body);
439 let expires = now() + 600;
440 let upload = lfs_token::issue(&KEY, &fx.repo_id, LfsOp::Upload, expires);
441 let download = lfs_token::issue(&KEY, &fx.repo_id, LfsOp::Download, expires);
442 let upload_auth = format!("RemoteAuth {upload}");
443 let download_auth = format!("RemoteAuth {download}");
444
445 let refused = send(
446 &fx.router,
447 "PUT",
448 &objects_uri(&format!("/{oid}")),
449 &[("authorization", &download_auth)],
450 body.to_vec(),
451 )
452 .await;
453 assert_eq!(refused.status, 401);
454
455 let stored = send(
456 &fx.router,
457 "PUT",
458 &objects_uri(&format!("/{oid}")),
459 &[("authorization", &upload_auth)],
460 body.to_vec(),
461 )
462 .await;
463 assert_eq!(stored.status, 200);
464
465 let got = send(
466 &fx.router,
467 "GET",
468 &objects_uri(&format!("/{oid}")),
469 &[("authorization", &download_auth)],
470 Vec::new(),
471 )
472 .await;
473 assert_eq!(got.status, 200);
474 assert_eq!(got.body, body);
475
476 let wrong_repo = lfs_token::issue(&KEY, "some-other-repo", LfsOp::Download, expires);
477 let got = send(
478 &fx.router,
479 "GET",
480 &objects_uri(&format!("/{oid}")),
481 &[("authorization", &format!("RemoteAuth {wrong_repo}"))],
482 Vec::new(),
483 )
484 .await;
485 assert_eq!(got.status, 401);
486}
487
488#[tokio::test]
489async fn verify_checks_presence_and_size() {
490 let fx = fixture(Visibility::Public).await;
491 let oid = upload_with_pat(&fx, b"verify me\n").await;
492 let auth = basic(OWNER, &fx.token);
493 let verify = |oid: String, size: u64| {
494 let body = serde_json::to_vec(&serde_json::json!({ "oid": oid, "size": size })).unwrap();
495 (body, auth.clone())
496 };
497
498 let (body, auth_value) = verify(oid.clone(), 10);
499 let ok = send(
500 &fx.router,
501 "POST",
502 &objects_uri("/verify"),
503 &[("authorization", &auth_value)],
504 body,
505 )
506 .await;
507 assert_eq!(ok.status, 200);
508
509 let (body, auth_value) = verify(oid.clone(), 11);
510 let mismatch = send(
511 &fx.router,
512 "POST",
513 &objects_uri("/verify"),
514 &[("authorization", &auth_value)],
515 body,
516 )
517 .await;
518 assert_eq!(mismatch.status, 422);
519
520 let (body, auth_value) = verify(oid_of(b"absent"), 6);
521 let absent = send(
522 &fx.router,
523 "POST",
524 &objects_uri("/verify"),
525 &[("authorization", &auth_value)],
526 body,
527 )
528 .await;
529 assert_eq!(absent.status, 404);
530}
531
532#[tokio::test]
533async fn archived_and_pull_mirror_repos_refuse_uploads() {
534 let fx = fixture(Visibility::Public).await;
535 let oid = oid_of(b"frozen\n");
536 let auth = basic(OWNER, &fx.token);
537 let batch = batch_body("upload", &[(oid.clone(), 7)]);
538
539 fx.store.set_repo_archived(&fx.repo_id, true).await.unwrap();
540 let reply = send(
541 &fx.router,
542 "POST",
543 &objects_uri("/batch"),
544 &[("authorization", &auth)],
545 batch.clone(),
546 )
547 .await;
548 assert_eq!(reply.status, 403);
549 let reply = send(
550 &fx.router,
551 "PUT",
552 &objects_uri(&format!("/{oid}")),
553 &[("authorization", &auth)],
554 b"frozen\n".to_vec(),
555 )
556 .await;
557 assert_eq!(reply.status, 403);
558 fx.store
559 .set_repo_archived(&fx.repo_id, false)
560 .await
561 .unwrap();
562
563 fx.store
564 .create_mirror(&NewMirror {
565 repo_id: &fx.repo_id,
566 direction: MirrorDirection::Pull,
567 url: "https://example.com/upstream.git",
568 username: "",
569 secret: None,
570 interval_minutes: 0,
571 })
572 .await
573 .unwrap();
574 let reply = send(
575 &fx.router,
576 "PUT",
577 &objects_uri(&format!("/{oid}")),
578 &[("authorization", &auth)],
579 b"frozen\n".to_vec(),
580 )
581 .await;
582 assert_eq!(reply.status, 403);
583 let reply = send(
584 &fx.router,
585 "POST",
586 &objects_uri("/batch"),
587 &[("authorization", &auth)],
588 batch,
589 )
590 .await;
591 assert_eq!(reply.status, 403);
592}
593
594#[tokio::test]
595async fn lock_paths_are_not_implemented() {
596 let fx = fixture(Visibility::Public).await;
597 let auth = basic(OWNER, &fx.token);
598 for (method, suffix) in [
599 ("GET", "/info/lfs/locks"),
600 ("POST", "/info/lfs/locks"),
601 ("POST", "/info/lfs/locks/verify"),
602 ("POST", "/info/lfs/locks/abc/unlock"),
603 ] {
604 let uri = format!("/{OWNER}/{REPO}.git{suffix}");
605 let reply = send(
606 &fx.router,
607 method,
608 &uri,
609 &[("authorization", &auth)],
610 Vec::new(),
611 )
612 .await;
613 assert_eq!(reply.status, 404, "{method} {uri}");
614 }
615}
616
617fn object_file(data: &std::path::Path, oid: &str) -> PathBuf {
618 crate::lfs::object_path(data, oid)
619}
620
621fn now() -> i64 {
622 std::time::SystemTime::now()
623 .duration_since(std::time::UNIX_EPOCH)
624 .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
625 .unwrap_or(0)
626}