Tahinli/forge
public0 watching
★ 0
| 1 | //! Route tests for the LFS API, driven through the public router. |
| 2 | |
| 3 | use std::path::PathBuf; |
| 4 | use std::sync::Arc; |
| 5 | |
| 6 | use base64::prelude::{BASE64_STANDARD, Engine}; |
| 7 | use forge_core::lfs_token::{self, LfsOp}; |
| 8 | use forge_core::mirrors::{MirrorDirection, NewMirror}; |
| 9 | use forge_core::token::{generate_token, hash_token}; |
| 10 | use forge_core::{Store, Visibility}; |
| 11 | use sha2::{Digest, Sha256}; |
| 12 | use topcoat::router::request::Request; |
| 13 | use topcoat::router::{Body, Router, to_bytes}; |
| 14 | |
| 15 | use crate::test_support::{KEY, app_router, test_state}; |
| 16 | |
| 17 | const OWNER: &str = "alice"; |
| 18 | const REPO: &str = "demo"; |
| 19 | |
| 20 | struct 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. |
| 30 | async 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 | |
| 60 | impl 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 | |
| 72 | fn basic(user: &str, token: &str) -> String { |
| 73 | format!( |
| 74 | "Basic {}", |
| 75 | BASE64_STANDARD.encode(format!("{user}:{token}")) |
| 76 | ) |
| 77 | } |
| 78 | |
| 79 | fn oid_of(bytes: &[u8]) -> String { |
| 80 | hex::encode(Sha256::digest(bytes)) |
| 81 | } |
| 82 | |
| 83 | struct Reply { |
| 84 | status: u16, |
| 85 | headers: http::HeaderMap, |
| 86 | body: Vec<u8>, |
| 87 | } |
| 88 | |
| 89 | impl Reply { |
| 90 | fn json(&self) -> serde_json::Value { |
| 91 | serde_json::from_slice(&self.body).unwrap() |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | async 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 | |
| 121 | fn objects_uri(suffix: &str) -> String { |
| 122 | format!("/{OWNER}/{REPO}.git/info/lfs/objects{suffix}") |
| 123 | } |
| 124 | |
| 125 | fn 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. |
| 139 | async 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] |
| 160 | async 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] |
| 202 | async 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] |
| 218 | async 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] |
| 256 | async 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] |
| 307 | async 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] |
| 324 | async 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] |
| 382 | async 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] |
| 409 | async 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] |
| 435 | async 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] |
| 489 | async 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] |
| 533 | async 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] |
| 595 | async 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 | |
| 617 | fn object_file(data: &std::path::Path, oid: &str) -> PathBuf { |
| 618 | crate::lfs::object_path(data, oid) |
| 619 | } |
| 620 | |
| 621 | fn 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 | } |