This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-xrpc / tests / reads.rs
66 kB 2042 lines
1mod common; 2 3use std::future::Future; 4use std::pin::Pin; 5use std::time::{Duration, Instant}; 6 7use futures::StreamExt; 8use futures::stream; 9use http::{HeaderMap, StatusCode, header}; 10use tokio_tungstenite::tungstenite; 11 12use knot_events::{EventCursor, GitRefUpdate}; 13use knot_types::{AccountDid, ObjectFormat, Oid, OwnerDid, RepoDid}; 14use knot_xrpc::{ArchiveLimit, ResponseLimit}; 15 16use common::{ 17 OWNER, World, archive_full, assert_immutable_round_trip, assert_post_rejected, assert_warming, 18 commit_file, empty_repo, get, get_error, get_json, get_with_headers, git_run, post_authed, 19 post_json, ref_names, repo_dids, seeded, seeded_feature_branch, seeded_with_format, sh_git, 20 sh_git_at, 21}; 22 23#[tokio::test] 24async fn the_seeded_read_surface_renders_each_wire_shape_once() { 25 let world = World::new(); 26 let (did, work) = seeded(&world, "coral"); 27 let head = sh_git(work.path(), &["rev-parse", "HEAD"]); 28 let parent = sh_git(work.path(), &["rev-parse", "HEAD~1"]); 29 let tag_object = sh_git(work.path(), &["rev-parse", "v1.0.0"]); 30 let tagged_commit = sh_git(work.path(), &["rev-parse", "v1.0.0^{commit}"]); 31 32 let tree = get_json( 33 &world, 34 &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main"), 35 ) 36 .await; 37 assert_eq!(tree["ref"], "main"); 38 assert!(tree.get("parent").is_none()); 39 assert!(tree.get("dotdot").is_none()); 40 let files = tree["files"].as_array().unwrap(); 41 let names: Vec<&str> = files 42 .iter() 43 .map(|file| file["name"].as_str().unwrap()) 44 .collect(); 45 assert_eq!(names, vec!["README.md", "logo.png", "src"]); 46 let readme_entry = &files[0]; 47 assert_eq!(readme_entry["mode"], "0100644"); 48 assert_eq!( 49 readme_entry["size"].as_i64().unwrap(), 50 b"# coral\n\nhello reef\n".len() as i64 51 ); 52 assert_eq!( 53 readme_entry["last_commit"]["hash"].as_str().unwrap(), 54 head, 55 "README was last touched by the head commit" 56 ); 57 assert_eq!(readme_entry["last_commit"]["message"], "update readme"); 58 assert_eq!(files[2]["mode"], "0040000"); 59 assert_eq!(tree["readme"]["filename"], "README.md"); 60 assert_eq!(tree["readme"]["contents"], "# coral\n\nhello reef\n"); 61 assert_eq!(tree["lastCommit"]["hash"], head.as_str()); 62 assert_eq!(tree["lastCommit"]["author"]["name"], "nel"); 63 assert_eq!(tree["lastCommit"]["author"]["when"], ""); 64 65 let sub = get_json( 66 &world, 67 &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main&path=src"), 68 ) 69 .await; 70 assert_eq!(sub["parent"], "src"); 71 assert!(sub.get("dotdot").is_none()); 72 assert_eq!(sub["files"][0]["name"], "main.rs"); 73 assert_eq!( 74 get_error( 75 &world, 76 &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main&path=nope"), 77 ) 78 .await, 79 (StatusCode::NOT_FOUND, "PathNotFound".to_string()) 80 ); 81 82 let log = get_json( 83 &world, 84 &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=main"), 85 ) 86 .await; 87 assert_eq!(log["total"].as_i64(), Some(4)); 88 assert_eq!(log["page"].as_i64(), Some(1)); 89 assert_eq!(log["per_page"].as_i64(), Some(50)); 90 assert_eq!(log["log"], true); 91 assert_eq!(log["ref"], "main"); 92 let commits = log["commits"].as_array().unwrap(); 93 assert_eq!(commits.len(), 4); 94 let first = &commits[0]; 95 let hash_bytes: Vec<u8> = first["hash"] 96 .as_array() 97 .unwrap() 98 .iter() 99 .map(|byte| byte.as_u64().unwrap() as u8) 100 .collect(); 101 assert_eq!( 102 hash_bytes, 103 (0..head.len()) 104 .step_by(2) 105 .map(|index| u8::from_str_radix(&head[index..index + 2], 16).unwrap()) 106 .collect::<Vec<u8>>(), 107 "commit hash rides as a byte array" 108 ); 109 assert_eq!(first["this"], head.as_str()); 110 assert_eq!(first["parent"], parent.as_str()); 111 assert_eq!(first["author"]["Name"], "nel"); 112 assert_eq!(first["author"]["Email"], "nel@oyster.cafe"); 113 assert_eq!(first["author"]["When"], "2026-06-01T12:33:00+02:00"); 114 assert_eq!(first["message"], "update readme\n"); 115 assert!(first["tree"].as_str().unwrap().len() == 40); 116 let paged = get_json( 117 &world, 118 &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=main&limit=2&cursor=2"), 119 ) 120 .await; 121 assert_eq!(paged["commits"].as_array().unwrap().len(), 2); 122 assert_eq!(paged["page"].as_i64(), Some(2)); 123 assert_eq!(paged["per_page"].as_i64(), Some(2)); 124 125 let branches = get_json( 126 &world, 127 &format!("/xrpc/sh.tangled.repo.branches?repo={did}"), 128 ) 129 .await; 130 let listed = branches["branches"].as_array().unwrap(); 131 assert_eq!(listed.len(), 1); 132 assert_eq!(listed[0]["reference"]["name"], "main"); 133 assert_eq!(listed[0]["reference"]["hash"], head.as_str()); 134 assert_eq!(listed[0]["is_default"], true); 135 assert_eq!(listed[0]["commit"]["Author"]["Name"], "nel"); 136 assert!(listed[0]["commit"]["Hash"].is_array()); 137 assert_eq!(listed[0]["commit"]["ExtraHeaders"], serde_json::Value::Null); 138 assert_eq!(listed[0]["commit"]["Message"], "update readme"); 139 let branch = get_json( 140 &world, 141 &format!("/xrpc/sh.tangled.repo.branch?repo={did}&name=main"), 142 ) 143 .await; 144 assert_eq!(branch["name"], "main"); 145 assert_eq!(branch["hash"], head.as_str()); 146 assert_eq!(branch["shortHash"], head[..7].to_string().as_str()); 147 assert_eq!(branch["isDefault"], true); 148 assert_eq!(branch["author"]["name"], "nel"); 149 assert_eq!(branch["when"], "2026-06-01T12:33:00+02:00"); 150 assert_eq!( 151 get_error( 152 &world, 153 &format!("/xrpc/sh.tangled.repo.branch?repo={did}&name=mangrove"), 154 ) 155 .await, 156 (StatusCode::NOT_FOUND, "BranchNotFound".to_string()) 157 ); 158 159 let tags = get_json(&world, &format!("/xrpc/sh.tangled.repo.tags?repo={did}")).await; 160 let tag_list = tags["tags"].as_array().unwrap(); 161 assert_eq!(tag_list.len(), 2); 162 let annotated = tag_list.iter().find(|tag| tag["name"] == "v1.0.0").unwrap(); 163 assert_eq!(annotated["hash"], tag_object.as_str()); 164 assert_eq!(annotated["message"], "release one"); 165 assert_eq!(annotated["tag"]["TargetType"].as_i64(), Some(4)); 166 assert_eq!(annotated["tag"]["Tagger"]["Name"], "nel"); 167 let target_bytes = annotated["tag"]["Target"].as_array().unwrap(); 168 assert_eq!(target_bytes.len(), 20); 169 assert_eq!( 170 target_bytes[0].as_u64().unwrap() as u8, 171 u8::from_str_radix(&tagged_commit[..2], 16).unwrap() 172 ); 173 let lightweight = tag_list 174 .iter() 175 .find(|tag| tag["name"] == "lightweight") 176 .unwrap(); 177 assert!(lightweight.get("tag").is_none()); 178 assert_eq!(lightweight["hash"], tagged_commit.as_str()); 179 assert_eq!(lightweight["message"], "add logo"); 180 let single = get_json( 181 &world, 182 &format!("/xrpc/sh.tangled.repo.tag?repo={did}&tag=v1.0.0"), 183 ) 184 .await; 185 assert_eq!(single["tag"]["name"], "v1.0.0"); 186 assert_eq!( 187 get_error( 188 &world, 189 &format!("/xrpc/sh.tangled.repo.tag?repo={did}&tag=v9.9.9"), 190 ) 191 .await, 192 (StatusCode::BAD_REQUEST, "TagNotFound".to_string()) 193 ); 194 195 let text = get_json( 196 &world, 197 &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=README.md"), 198 ) 199 .await; 200 assert_eq!(text["encoding"], "utf-8"); 201 assert_eq!(text["isBinary"], false); 202 assert_eq!(text["content"], "# coral\n\nhello reef\n"); 203 assert_eq!(text["mimeType"], "text/plain; charset=utf-8"); 204 assert_eq!(text["lastCommit"]["message"], "update readme"); 205 206 let binary = get_json( 207 &world, 208 &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=logo.png"), 209 ) 210 .await; 211 assert_eq!(binary["encoding"], "base64"); 212 assert_eq!(binary["isBinary"], true); 213 assert_eq!(binary["mimeType"], "image/png"); 214 215 let (status, headers, body) = get( 216 &world, 217 &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=logo.png&raw=true"), 218 ) 219 .await; 220 assert_eq!(status, StatusCode::OK); 221 assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/png"); 222 assert_eq!( 223 headers.get(header::X_CONTENT_TYPE_OPTIONS).unwrap(), 224 "nosniff" 225 ); 226 assert_eq!( 227 headers.get(header::CONTENT_SECURITY_POLICY).unwrap(), 228 "default-src 'none'; style-src 'unsafe-inline'; sandbox" 229 ); 230 let etag = headers 231 .get(header::ETAG) 232 .unwrap() 233 .to_str() 234 .unwrap() 235 .to_string(); 236 assert!(body.starts_with(b"\x89PNG")); 237 238 let mut cached = HeaderMap::new(); 239 cached.insert( 240 header::IF_NONE_MATCH, 241 http::HeaderValue::from_str(&etag).unwrap(), 242 ); 243 let (status, _, _) = get_with_headers( 244 &world, 245 &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=logo.png&raw=true"), 246 cached, 247 ) 248 .await; 249 assert_eq!(status, StatusCode::NOT_MODIFIED); 250 251 let mut weak = HeaderMap::new(); 252 weak.insert( 253 header::IF_NONE_MATCH, 254 http::HeaderValue::from_str(&format!("W/{etag}")).unwrap(), 255 ); 256 let (status, _, _) = get_with_headers( 257 &world, 258 &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=logo.png&raw=true"), 259 weak, 260 ) 261 .await; 262 assert_eq!( 263 status, 264 StatusCode::NOT_MODIFIED, 265 "weak validator must revalidate too" 266 ); 267 assert_eq!( 268 get_error( 269 &world, 270 &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=ghost.txt"), 271 ) 272 .await, 273 (StatusCode::NOT_FOUND, "FileNotFound".to_string()) 274 ); 275} 276 277#[tokio::test] 278async fn tree_directory_last_commit_is_the_newest_touching_commit() { 279 let world = World::new(); 280 let (did, bare, work) = empty_repo(&world, "periwinkle"); 281 let work = work.path(); 282 commit_file( 283 work, 284 "src/a.rs", 285 b"fn a() {}\n", 286 "add a", 287 "2026-06-01T12:30:00+02:00", 288 ); 289 let older = sh_git(work, &["rev-parse", "HEAD"]); 290 commit_file( 291 work, 292 "src/b.rs", 293 b"fn b() {}\n", 294 "add b", 295 "2026-06-01T12:31:00+02:00", 296 ); 297 let newer = sh_git(work, &["rev-parse", "HEAD"]); 298 commit_file( 299 work, 300 "README.md", 301 b"# periwinkle\n", 302 "doc", 303 "2026-06-01T12:33:00+02:00", 304 ); 305 let head = sh_git(work, &["rev-parse", "HEAD"]); 306 sh_git(work, &["push", "-q", &bare, "main"]); 307 308 let value = get_json( 309 &world, 310 &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main"), 311 ) 312 .await; 313 let files = value["files"].as_array().unwrap(); 314 let src = files 315 .iter() 316 .find(|file| file["name"] == "src") 317 .expect("src directory is listed"); 318 let reported = src["last_commit"]["hash"].as_str().unwrap(); 319 assert_eq!( 320 reported, newer, 321 "the newest commit that touches the subtree is reported, not the oldest" 322 ); 323 assert_ne!(reported, older, "not the first commit that created subtree"); 324 assert_ne!( 325 reported, head, 326 "head commit only touched README, never the src subtree" 327 ); 328} 329 330#[tokio::test] 331async fn languages_timeout_yields_a_partial_answer_not_an_error() { 332 let world = World::new(); 333 let (did, work) = seeded(&world, "scallop"); 334 let head = Oid::from_hex(&sh_git(work.path(), &["rev-parse", "HEAD"])).unwrap(); 335 let repo = world.layout.open(&did).unwrap(); 336 337 let full = 338 knot_langs::analyze(&repo, head, Some(Instant::now() + Duration::from_secs(60))).unwrap(); 339 assert!( 340 full.values().any(|size| size.get() > 0), 341 "generous budget detects code" 342 ); 343 344 let expired = Instant::now() 345 .checked_sub(Duration::from_secs(1)) 346 .unwrap_or_else(Instant::now); 347 let partial = knot_langs::analyze(&repo, head, Some(expired)).unwrap(); 348 assert!( 349 partial.is_empty(), 350 "exhausted budget breaks the walk and returns the partial map gathered so far, never an error" 351 ); 352} 353 354#[tokio::test] 355async fn compare_format_patch_keeps_a_non_ascii_author_raw() { 356 let world = World::new(); 357 let (did, bare, work) = empty_repo(&world, "mussel"); 358 let work = work.path(); 359 commit_file( 360 work, 361 "README.md", 362 b"# mussel\n", 363 "first", 364 "2026-06-01T12:30:00+02:00", 365 ); 366 let base = sh_git(work, &["rev-parse", "HEAD"]); 367 std::fs::write(work.join("src.rs"), b"fn main() {}\n").unwrap(); 368 let author = ("Lýna Þórsdóttir", "lyna@nel.pet"); 369 git_run(work, "2026-06-01T12:31:00+02:00", author, &["add", "-A"]); 370 git_run( 371 work, 372 "2026-06-01T12:31:00+02:00", 373 author, 374 &["commit", "-q", "-m", "café changes"], 375 ); 376 let head = sh_git(work, &["rev-parse", "HEAD"]); 377 sh_git(work, &["push", "-q", &bare, "main"]); 378 379 let value = get_json( 380 &world, 381 &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={base}&rev2={head}"), 382 ) 383 .await; 384 let entry = &value["format_patch"][0]; 385 assert_eq!( 386 entry["Author"]["Name"], "Lýna Þórsdóttir", 387 "structured author the appview renders keeps the raw unicode" 388 ); 389 assert_eq!(entry["Author"]["Email"], "lyna@nel.pet"); 390 assert_eq!( 391 entry["Title"], "café changes", 392 "structured subject the appview renders keeps the raw unicode" 393 ); 394 assert_eq!(entry["RawHeaders"]["Subject"][0], "[PATCH] café changes"); 395 assert_eq!( 396 entry["RawHeaders"]["From"][0], 397 "Lýna Þórsdóttir <lyna@nel.pet>" 398 ); 399 let raw = entry["Raw"].as_str().unwrap(); 400 assert!( 401 raw.contains("From: Lýna Þórsdóttir <lyna@nel.pet>"), 402 "knot emits the raw UTF-8 author instead of RFC2047 Q-encoding real format-patch uses" 403 ); 404 assert!( 405 !raw.contains("=?UTF-8?") && !raw.contains("=?utf-8?"), 406 "no MIME word-encoding headers" 407 ); 408 assert!(raw.contains("Subject: [PATCH] café changes")); 409 assert!( 410 raw.ends_with("-- \nknot"), 411 "knot signs the patch w/ its own trailer" 412 ); 413} 414 415#[tokio::test] 416async fn diff_reports_structured_fragments_and_stats() { 417 let world = World::new(); 418 let (did, work) = seeded(&world, "whelk"); 419 let head = sh_git(work.path(), &["rev-parse", "HEAD"]); 420 421 let value = get_json( 422 &world, 423 &format!("/xrpc/sh.tangled.repo.diff?repo={did}&ref={head}"), 424 ) 425 .await; 426 assert_eq!(value["ref"], head.as_str()); 427 let diff = &value["diff"]; 428 assert_eq!(diff["stat"]["files_changed"].as_i64(), Some(1)); 429 assert_eq!(diff["stat"]["insertions"].as_i64(), Some(1)); 430 assert_eq!(diff["stat"]["deletions"].as_i64(), Some(1)); 431 let file = &diff["diff"][0]; 432 assert_eq!(file["name"]["new"], "README.md"); 433 assert_eq!(file["is_new"], false); 434 let fragment = &file["text_fragments"][0]; 435 assert_eq!(fragment["OldPosition"].as_i64(), Some(1)); 436 assert_eq!(fragment["Comment"], ""); 437 let lines = fragment["Lines"].as_array().unwrap(); 438 assert!( 439 lines 440 .iter() 441 .any(|line| line["Op"].as_i64() == Some(1) && line["Line"] == "hello\n") 442 ); 443 assert!( 444 lines 445 .iter() 446 .any(|line| line["Op"].as_i64() == Some(2) && line["Line"] == "hello reef\n") 447 ); 448 assert_eq!(diff["commit"]["this"], head.as_str()); 449} 450 451#[tokio::test] 452async fn compare_produces_format_patches_and_a_combined_patch() { 453 let world = World::new(); 454 let (did, work) = seeded(&world, "conch"); 455 let head = sh_git(work.path(), &["rev-parse", "HEAD"]); 456 let base = sh_git(work.path(), &["rev-parse", "HEAD~3"]); 457 458 let value = get_json( 459 &world, 460 &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={base}&rev2={head}"), 461 ) 462 .await; 463 assert_eq!(value["rev1"], base.as_str()); 464 assert_eq!(value["rev2"], head.as_str()); 465 let patches = value["format_patch"].as_array().unwrap(); 466 assert_eq!(patches.len(), 3, "three commits separate base from head"); 467 let first = &patches[0]; 468 assert_eq!(first["Title"], "add main"); 469 assert_eq!(first["SubjectPrefix"], "[PATCH] "); 470 assert_eq!(first["Committer"], serde_json::Value::Null); 471 assert_eq!(first["CommitterDate"], "0001-01-01T00:00:00Z"); 472 assert_eq!(first["Author"]["Name"], "nel"); 473 assert_eq!(first["AuthorDate"], "2026-06-01T12:31:00+02:00"); 474 assert_eq!(first["RawHeaders"]["Subject"][0], "[PATCH] add main"); 475 let raw = first["Raw"].as_str().unwrap(); 476 assert!(raw.starts_with(&format!( 477 "From {} Mon Sep 17 00:00:00 2001\n", 478 sh_git(work.path(), &["rev-parse", "HEAD~2"]) 479 ))); 480 assert!(raw.contains("Subject: [PATCH] add main")); 481 assert!(raw.contains("diff --git a/src/main.rs b/src/main.rs")); 482 assert!(raw.contains("new file mode 100644")); 483 assert!(first["Files"][0]["NewName"] == "src/main.rs"); 484 assert!(first["Files"][0]["IsNew"] == true); 485 486 assert!(value["patch"].as_str().unwrap().contains("add logo")); 487 let combined = value["combined_patch"].as_array().unwrap(); 488 assert!(combined.iter().any(|file| file["NewName"] == "README.md")); 489 assert!( 490 value["combined_patch_raw"] 491 .as_str() 492 .unwrap() 493 .contains("diff --git a/README.md b/README.md") 494 ); 495 496 assert_eq!( 497 get_error( 498 &world, 499 &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1=nope&rev2={head}"), 500 ) 501 .await, 502 (StatusCode::BAD_REQUEST, "RevisionNotFound".to_string()) 503 ); 504} 505 506#[tokio::test] 507async fn archive_conditional_and_range_semantics() { 508 let world = World::new(); 509 let (did, _work) = seeded(&world, "nautilus"); 510 let path = format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main"); 511 512 let (status, headers, full) = get(&world, &path).await; 513 assert_eq!(status, StatusCode::OK); 514 assert_eq!( 515 headers.get(header::CONTENT_TYPE).unwrap(), 516 "application/gzip" 517 ); 518 assert_eq!( 519 headers 520 .get(header::CONTENT_DISPOSITION) 521 .unwrap() 522 .to_str() 523 .unwrap(), 524 format!("attachment; filename=\"{did}-main.tar.gz\"") 525 ); 526 let link = headers.get(header::LINK).unwrap().to_str().unwrap(); 527 assert!(link.contains("rel=\"immutable\"")); 528 assert!(link.contains("/xrpc/sh.tangled.repo.archive?format=tar.gz")); 529 assert_eq!(headers.get(header::ACCEPT_RANGES).unwrap(), "bytes"); 530 let last_modified = headers 531 .get(header::LAST_MODIFIED) 532 .expect("a pinned modification time backs date revalidation") 533 .to_str() 534 .unwrap() 535 .to_string(); 536 let etag = headers 537 .get(header::ETAG) 538 .unwrap() 539 .to_str() 540 .unwrap() 541 .to_string(); 542 assert!( 543 etag.starts_with('"') && etag.ends_with('"'), 544 "a strong etag is quoted" 545 ); 546 assert_eq!(&full[..2], &[0x1f, 0x8b]); 547 548 let mut range = HeaderMap::new(); 549 range.insert(header::RANGE, "bytes=0-3".parse().unwrap()); 550 let (status, range_headers, partial) = get_with_headers(&world, &path, range).await; 551 assert_eq!(status, StatusCode::PARTIAL_CONTENT); 552 assert_eq!( 553 range_headers 554 .get(header::CONTENT_RANGE) 555 .unwrap() 556 .to_str() 557 .unwrap(), 558 format!("bytes 0-3/{}", full.len()) 559 ); 560 assert_eq!( 561 partial.as_ref(), 562 &full[..4], 563 "a resumed range regenerates byte for byte" 564 ); 565 566 let mut conditional = HeaderMap::new(); 567 conditional.insert(header::IF_NONE_MATCH, etag.parse().unwrap()); 568 let (status, cond_headers, conditional_body) = 569 get_with_headers(&world, &path, conditional).await; 570 assert_eq!(status, StatusCode::NOT_MODIFIED); 571 assert_eq!( 572 cond_headers.get(header::ETAG).unwrap().to_str().unwrap(), 573 etag 574 ); 575 assert!(conditional_body.is_empty(), "a 304 has no body"); 576 577 let (status, _) = get_error( 578 &world, 579 &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main&format=tar.bz2"), 580 ) 581 .await; 582 assert_eq!(status, StatusCode::BAD_REQUEST); 583 584 let mut etag_match = HeaderMap::new(); 585 etag_match.insert(header::RANGE, "bytes=0-3".parse().unwrap()); 586 etag_match.insert(header::IF_RANGE, etag.parse().unwrap()); 587 let (status, if_range_headers, partial) = get_with_headers(&world, &path, etag_match).await; 588 assert_eq!( 589 status, 590 StatusCode::PARTIAL_CONTENT, 591 "a matching content etag resumes the range" 592 ); 593 assert_eq!( 594 if_range_headers 595 .get(header::CONTENT_RANGE) 596 .unwrap() 597 .to_str() 598 .unwrap(), 599 format!("bytes 0-3/{}", full.len()) 600 ); 601 assert_eq!(partial.as_ref(), &full[..4]); 602 603 let mut etag_stale = HeaderMap::new(); 604 etag_stale.insert(header::RANGE, "bytes=0-3".parse().unwrap()); 605 etag_stale.insert(header::IF_RANGE, "\"0000\"".parse().unwrap()); 606 let (status, _, body) = get_with_headers(&world, &path, etag_stale).await; 607 assert_eq!( 608 status, 609 StatusCode::OK, 610 "a stale content etag falls back to the full body" 611 ); 612 assert_eq!(body, full, "the full archive comes back byte for byte"); 613 614 let mut weak = HeaderMap::new(); 615 weak.insert(header::RANGE, "bytes=0-3".parse().unwrap()); 616 weak.insert(header::IF_RANGE, format!("W/{etag}").parse().unwrap()); 617 let (status, _, body) = get_with_headers(&world, &path, weak).await; 618 assert_eq!( 619 status, 620 StatusCode::OK, 621 "a weak validator never serves a range, per strong-comparison rules" 622 ); 623 assert_eq!(body, full); 624 625 let mut date_match = HeaderMap::new(); 626 date_match.insert(header::RANGE, "bytes=0-3".parse().unwrap()); 627 date_match.insert(header::IF_RANGE, last_modified.parse().unwrap()); 628 let (status, date_headers, partial) = get_with_headers(&world, &path, date_match).await; 629 assert_eq!( 630 status, 631 StatusCode::PARTIAL_CONTENT, 632 "a date matching the pinned last-modified resumes the range" 633 ); 634 assert_eq!( 635 date_headers 636 .get(header::CONTENT_RANGE) 637 .unwrap() 638 .to_str() 639 .unwrap(), 640 format!("bytes 0-3/{}", full.len()) 641 ); 642 assert_eq!(partial.as_ref(), &full[..4]); 643 644 let mut date_stale = HeaderMap::new(); 645 date_stale.insert(header::RANGE, "bytes=0-3".parse().unwrap()); 646 date_stale.insert( 647 header::IF_RANGE, 648 "Wed, 21 Oct 2015 07:28:00 GMT".parse().unwrap(), 649 ); 650 let (status, _, body) = get_with_headers(&world, &path, date_stale).await; 651 assert_eq!( 652 status, 653 StatusCode::OK, 654 "a date that doesn't match the pinned last-modified falls back to the full body" 655 ); 656 assert_eq!(body, full); 657 658 assert_immutable_round_trip(&world, &headers, &full, &etag).await; 659} 660 661#[tokio::test] 662async fn archive_etag_distinguishes_refs_that_share_a_commit() { 663 let world = World::new(); 664 let (did, work) = seeded(&world, "scallop"); 665 let bare = world.layout.repo_path(&did).unwrap(); 666 sh_git(work.path(), &["branch", "release", "main"]); 667 sh_git( 668 work.path(), 669 &["push", "-q", bare.to_str().unwrap(), "refs/heads/release"], 670 ); 671 672 let (main_etag, _main_last_modified, main_body) = archive_full(&world, &did).await; 673 let (status, release_headers, release_body) = get( 674 &world, 675 &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=release"), 676 ) 677 .await; 678 assert_eq!(status, StatusCode::OK); 679 let release_etag = release_headers 680 .get(header::ETAG) 681 .unwrap() 682 .to_str() 683 .unwrap() 684 .to_string(); 685 assert_ne!( 686 main_etag, release_etag, 687 "two refs at one commit name different archive prefixes, so the strong etag must differ" 688 ); 689 assert_ne!( 690 main_body, release_body, 691 "the archives use different top-level directories and differ byte for byte" 692 ); 693 694 let mut conditional = HeaderMap::new(); 695 conditional.insert(header::IF_NONE_MATCH, main_etag.parse().unwrap()); 696 let (status, _, _) = get_with_headers( 697 &world, 698 &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=release"), 699 conditional, 700 ) 701 .await; 702 assert_eq!( 703 status, 704 StatusCode::OK, 705 "main's etag mustn't satisfy a conditional request for the release archive" 706 ); 707} 708 709#[tokio::test] 710async fn archive_serves_a_sha256_repo_with_a_stable_etag() { 711 let world = World::sha256(); 712 let (did, _work) = seeded_with_format(&world, "nautilus", ObjectFormat::SHA256); 713 714 let (status, headers, full) = get( 715 &world, 716 &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main"), 717 ) 718 .await; 719 assert_eq!(status, StatusCode::OK); 720 assert_eq!( 721 headers.get(header::CONTENT_TYPE).unwrap(), 722 "application/gzip" 723 ); 724 assert_eq!(&full[..2], &[0x1f, 0x8b]); 725 let etag = headers 726 .get(header::ETAG) 727 .unwrap() 728 .to_str() 729 .unwrap() 730 .to_string(); 731 732 assert_immutable_round_trip(&world, &headers, &full, &etag).await; 733 734 let mut conditional = HeaderMap::new(); 735 conditional.insert(header::IF_NONE_MATCH, etag.parse().unwrap()); 736 let (status, _, body) = get_with_headers( 737 &world, 738 &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main"), 739 conditional, 740 ) 741 .await; 742 assert_eq!( 743 status, 744 StatusCode::NOT_MODIFIED, 745 "conditional revalidation works under sha256" 746 ); 747 assert!(body.is_empty()); 748} 749 750#[tokio::test] 751async fn languages_detect_rust_and_markdown_stays_out() { 752 let world = World::new(); 753 let (did, _work) = seeded(&world, "uni"); 754 755 let value = get_json( 756 &world, 757 &format!("/xrpc/sh.tangled.repo.languages?repo={did}&ref=main"), 758 ) 759 .await; 760 let languages = value["languages"].as_array().unwrap(); 761 assert_eq!( 762 languages.len(), 763 1, 764 "only Rust counts: markdown is prose, png is binary" 765 ); 766 assert_eq!(languages[0]["name"], "Rust"); 767 assert_eq!(languages[0]["percentage"].as_i64(), Some(100)); 768 assert!(languages[0]["size"].as_i64().unwrap() > 0); 769 assert_eq!(value["totalFiles"].as_i64(), Some(1)); 770} 771 772#[tokio::test] 773async fn repo_metadata_resolves_and_fails_closed() { 774 let world = World::new(); 775 let (did, _work) = seeded(&world, "cuttle"); 776 777 let value = get_json( 778 &world, 779 &format!("/xrpc/sh.tangled.repo.getDefaultBranch?repo={did}"), 780 ) 781 .await; 782 assert_eq!(value["name"], "main"); 783 assert_eq!(value["hash"], ""); 784 assert_eq!(value["when"], "1970-01-01T00:00:00Z"); 785 786 let described = get_json( 787 &world, 788 &format!("/xrpc/sh.tangled.repo.describeRepo?repoDid={did}"), 789 ) 790 .await; 791 assert_eq!(described["repoDid"], did.as_str()); 792 assert_eq!(described["ownerDid"], OWNER); 793 assert_eq!(described["rkey"], "cuttle"); 794 assert_eq!( 795 get_error( 796 &world, 797 "/xrpc/sh.tangled.repo.describeRepo?repoDid=did:plc:doesnotexist", 798 ) 799 .await, 800 (StatusCode::NOT_FOUND, "RepoNotFound".to_string()) 801 ); 802 803 let by_owner = get_json( 804 &world, 805 &format!("/xrpc/sh.tangled.repo.getDefaultBranch?repo={OWNER}/cuttle"), 806 ) 807 .await; 808 assert_eq!(by_owner["name"], "main"); 809 assert_eq!( 810 get_error( 811 &world, 812 "/xrpc/sh.tangled.repo.getDefaultBranch?repo=did:plc:unregistered", 813 ) 814 .await, 815 (StatusCode::NOT_FOUND, "RepoNotFound".to_string()) 816 ); 817 let (status, _) = get_error(&world, "/xrpc/sh.tangled.repo.getDefaultBranch?repo=oyster").await; 818 assert_eq!(status, StatusCode::BAD_REQUEST); 819 assert_eq!( 820 get_error( 821 &world, 822 &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=mangrove"), 823 ) 824 .await, 825 (StatusCode::NOT_FOUND, "RefNotFound".to_string()) 826 ); 827 828 let (status, error) = get_error(&world, "/xrpc/sh.tangled.repo.getDefaultBranch").await; 829 assert_eq!(status, StatusCode::BAD_REQUEST); 830 assert_eq!( 831 error, "InvalidRequest", 832 "a structurally unsound query gets the lexicon error shape instead of the runtime's default plaintext" 833 ); 834} 835 836#[tokio::test] 837async fn list_refs_reports_paginates_and_drains() { 838 let world = World::new(); 839 let (did, work) = seeded(&world, "whelk"); 840 let head = sh_git(work.path(), &["rev-parse", "HEAD"]); 841 842 let value = get_json(&world, &format!("/xrpc/sh.tangled.git.listRefs?repo={did}")).await; 843 assert_eq!( 844 ref_names(&value, "refs"), 845 vec![ 846 "refs/heads/main", 847 "refs/tags/lightweight", 848 "refs/tags/v1.0.0" 849 ] 850 ); 851 let main = value["refs"] 852 .as_array() 853 .unwrap() 854 .iter() 855 .find(|entry| entry["ref"] == "refs/heads/main") 856 .unwrap(); 857 assert_eq!(main["sha"], head); 858 assert_eq!(value["defaultBranch"]["ref"], "refs/heads/main"); 859 assert_eq!(value["defaultBranch"]["head"], head); 860 assert!(value["cursor"].is_null()); 861 862 let first = get_json( 863 &world, 864 &format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=2"), 865 ) 866 .await; 867 assert_eq!(ref_names(&first, "refs").len(), 2); 868 let cursor = first["cursor"].as_str().unwrap().to_string(); 869 let second = get_json( 870 &world, 871 &format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=2&cursor={cursor}"), 872 ) 873 .await; 874 assert_eq!(ref_names(&second, "refs").len(), 1); 875 assert!(second["cursor"].is_null()); 876 877 let refs = get_json( 878 &world, 879 &format!( 880 "/xrpc/sh.tangled.git.listRefs?repo={did}&cursor={}", 881 usize::MAX 882 ), 883 ) 884 .await; 885 assert!(ref_names(&refs, "refs").is_empty()); 886 assert!(refs["cursor"].is_null()); 887 let repos = get_json( 888 &world, 889 &format!("/xrpc/sh.tangled.sync.listRepos?cursor={}", usize::MAX), 890 ) 891 .await; 892 assert!(repo_dids(&repos).is_empty()); 893 assert!(repos["cursor"].is_null()); 894} 895 896#[tokio::test] 897async fn a_hidden_staging_ref_resolves_for_fork_comparison_reads() { 898 let world = World::new(); 899 let (did, work) = seeded(&world, "limpet"); 900 let bare = world.layout.repo_path(&did).unwrap(); 901 902 sh_git(work.path(), &["checkout", "-q", "-b", "upstream"]); 903 commit_file( 904 work.path(), 905 "upstream.txt", 906 b"upstream\n", 907 "upstream moved", 908 "2026-06-01T12:50:00+02:00", 909 ); 910 let upstream = sh_git(work.path(), &["rev-parse", "HEAD"]); 911 sh_git( 912 work.path(), 913 &[ 914 "push", 915 "-q", 916 bare.to_str().unwrap(), 917 "HEAD:refs/hidden/main/main", 918 ], 919 ); 920 sh_git(work.path(), &["checkout", "-q", "main"]); 921 commit_file( 922 work.path(), 923 "ours.txt", 924 b"ours\n", 925 "fork work", 926 "2026-06-01T12:55:00+02:00", 927 ); 928 sh_git( 929 work.path(), 930 &["push", "-q", bare.to_str().unwrap(), "HEAD:refs/heads/main"], 931 ); 932 933 let comparison = get_json( 934 &world, 935 &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1=hidden/main/main&rev2=main"), 936 ) 937 .await; 938 assert_eq!(comparison["rev1"].as_str().unwrap(), upstream); 939 assert!(!comparison["format_patch"].as_array().unwrap().is_empty()); 940 941 let log = get_json( 942 &world, 943 &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=refs/hidden/main/main"), 944 ) 945 .await; 946 assert!(!log["commits"].as_array().unwrap().is_empty()); 947 948 let (status, _) = get_error( 949 &world, 950 &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref={upstream}"), 951 ) 952 .await; 953 assert_eq!( 954 status, 955 StatusCode::NOT_FOUND, 956 "a raw oid reachable only through the hidden ref mustn't resolve" 957 ); 958 let (status, error) = get_error( 959 &world, 960 &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={upstream}&rev2=main"), 961 ) 962 .await; 963 assert_eq!(status, StatusCode::BAD_REQUEST); 964 assert_eq!(error, "RevisionNotFound"); 965} 966 967#[tokio::test] 968async fn the_cob_ref_namespace_is_invisible_across_every_read() { 969 let world = World::new(); 970 let (did, work) = seeded(&world, "anemone"); 971 let head = sh_git(work.path(), &["rev-parse", "HEAD"]); 972 let bare = world.layout.repo_path(&did).unwrap(); 973 let cob = "refs/cobs/sh.tangled.repo.collaborator/x"; 974 sh_git(bare.as_path(), &["update-ref", cob, &head]); 975 976 let value = get_json(&world, &format!("/xrpc/sh.tangled.git.listRefs?repo={did}")).await; 977 assert!( 978 ref_names(&value, "refs") 979 .iter() 980 .all(|name| !name.starts_with("refs/cobs/")), 981 "reserved cob ref leaked into listRefs" 982 ); 983 984 let w = &world; 985 let d = &did; 986 let named_routes: &[(&str, &str)] = &[ 987 ("log", ""), 988 ("tree", ""), 989 ("blob", "&path=README.md"), 990 ("diff", ""), 991 ("archive", ""), 992 ("languages", ""), 993 ]; 994 stream::iter(named_routes) 995 .for_each(|&(route, suffix)| async move { 996 let (status, _) = get_error( 997 w, 998 &format!("/xrpc/sh.tangled.repo.{route}?repo={d}&ref={cob}{suffix}"), 999 ) 1000 .await; 1001 assert_eq!( 1002 status, 1003 StatusCode::NOT_FOUND, 1004 "{route} mustn't resolve reserved cobs ref" 1005 ); 1006 }) 1007 .await; 1008 let (status, _) = get_error( 1009 &world, 1010 &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=cobs/sh.tangled.repo.collaborator/x"), 1011 ) 1012 .await; 1013 assert_eq!( 1014 status, 1015 StatusCode::NOT_FOUND, 1016 "reserved namespace shorthand mustn't resolve either" 1017 ); 1018 let (status, _) = get_error( 1019 &world, 1020 &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={cob}&rev2={head}"), 1021 ) 1022 .await; 1023 assert_eq!(status, StatusCode::BAD_REQUEST); 1024 1025 commit_file( 1026 work.path(), 1027 "secret.txt", 1028 b"hidden\n", 1029 "secret", 1030 "2026-06-01T12:40:00+02:00", 1031 ); 1032 let hidden = sh_git(work.path(), &["rev-parse", "HEAD"]); 1033 sh_git( 1034 work.path(), 1035 &[ 1036 "push", 1037 "-q", 1038 bare.to_str().unwrap(), 1039 "HEAD:refs/cobs/sh.tangled.repo.collaborator/secret", 1040 ], 1041 ); 1042 1043 let hidden_ref = &hidden; 1044 let hidden_routes: &[(&str, &str)] = &[ 1045 ("log", ""), 1046 ("tree", "&path=secret.txt"), 1047 ("diff", ""), 1048 ("archive", ""), 1049 ("languages", ""), 1050 ]; 1051 stream::iter(hidden_routes) 1052 .for_each(|&(route, suffix)| async move { 1053 let (status, _) = get_error( 1054 w, 1055 &format!("/xrpc/sh.tangled.repo.{route}?repo={d}&ref={hidden_ref}{suffix}"), 1056 ) 1057 .await; 1058 assert_eq!( 1059 status, 1060 StatusCode::NOT_FOUND, 1061 "{route} mustn't serve a commit reachable only through cob ref" 1062 ); 1063 }) 1064 .await; 1065 let (status, _) = get_error( 1066 &world, 1067 &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref={hidden}&path=secret.txt"), 1068 ) 1069 .await; 1070 assert_eq!(status, StatusCode::NOT_FOUND); 1071 let (status, error) = get_error( 1072 &world, 1073 &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={head}&rev2={hidden}"), 1074 ) 1075 .await; 1076 assert_eq!(status, StatusCode::BAD_REQUEST); 1077 assert_eq!(error, "RevisionNotFound"); 1078 1079 let still_public = get_json( 1080 &world, 1081 &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref={head}"), 1082 ) 1083 .await; 1084 assert_eq!(still_public["total"].as_i64(), Some(4)); 1085} 1086 1087#[tokio::test] 1088async fn the_list_reads_reject_malformed_paging_params() { 1089 let world = World::new(); 1090 let (did, _work) = seeded(&world, "barnacle"); 1091 1092 let queries: Vec<String> = vec![ 1093 format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=abc"), 1094 format!("/xrpc/sh.tangled.git.listRefs?repo={did}&cursor=notanint"), 1095 "/xrpc/sh.tangled.sync.listRepos?limit=abc".to_string(), 1096 "/xrpc/sh.tangled.sync.listRepos?cursor=notanint".to_string(), 1097 "/xrpc/sh.tangled.sync.listRepos?order=sideways".to_string(), 1098 ]; 1099 let w = &world; 1100 stream::iter(queries.iter()) 1101 .for_each(|query| async move { 1102 let (status, error) = get_error(w, query).await; 1103 assert_eq!(status, StatusCode::BAD_REQUEST, "query {query}"); 1104 assert_eq!(error, "InvalidRequest", "query {query}"); 1105 }) 1106 .await; 1107 1108 let clamped = get_json( 1109 &world, 1110 &format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=5000"), 1111 ) 1112 .await; 1113 assert_eq!( 1114 ref_names(&clamped, "refs").len(), 1115 3, 1116 "an oversize limit clamps to the max instead of erroring" 1117 ); 1118} 1119 1120#[tokio::test] 1121async fn list_repos_lists_hosted_repos_with_order_and_pagination() { 1122 let world = World::new(); 1123 let (mussel, _a) = seeded(&world, "mussel"); 1124 let (nautilus, _b) = seeded(&world, "nautilus"); 1125 let (scallop, _c) = seeded(&world, "scallop"); 1126 1127 let desc = get_json(&world, "/xrpc/sh.tangled.sync.listRepos").await; 1128 assert_eq!( 1129 repo_dids(&desc), 1130 vec![ 1131 scallop.as_str().to_string(), 1132 nautilus.as_str().to_string(), 1133 mussel.as_str().to_string(), 1134 ] 1135 ); 1136 assert_eq!(desc["repos"][0]["status"], "active"); 1137 assert_eq!(desc["repos"][0]["defaultBranch"]["ref"], "refs/heads/main"); 1138 1139 let asc = get_json(&world, "/xrpc/sh.tangled.sync.listRepos?order=asc").await; 1140 assert_eq!( 1141 repo_dids(&asc), 1142 vec![ 1143 mussel.as_str().to_string(), 1144 nautilus.as_str().to_string(), 1145 scallop.as_str().to_string(), 1146 ] 1147 ); 1148 1149 let page = get_json(&world, "/xrpc/sh.tangled.sync.listRepos?order=asc&limit=2").await; 1150 assert_eq!(page["repos"].as_array().unwrap().len(), 2); 1151 let cursor = page["cursor"].as_str().unwrap().to_string(); 1152 let rest = get_json( 1153 &world, 1154 &format!("/xrpc/sh.tangled.sync.listRepos?order=asc&limit=2&cursor={cursor}"), 1155 ) 1156 .await; 1157 assert_eq!(repo_dids(&rest), vec![scallop.as_str().to_string()]); 1158 assert!(rest["cursor"].is_null()); 1159} 1160 1161#[tokio::test] 1162async fn every_projection_read_fails_closed_while_warming() { 1163 let world = World::warming(); 1164 let did = RepoDid::new("did:plc:limpetfixture").unwrap(); 1165 let cases: Vec<(String, Option<&str>)> = vec![ 1166 ( 1167 "/xrpc/sh.tangled.sync.listRepos".to_string(), 1168 Some("ProjectionWarming"), 1169 ), 1170 ( 1171 format!("/xrpc/sh.tangled.repo.getDefaultBranch?repo={did}"), 1172 Some("ProjectionWarming"), 1173 ), 1174 ( 1175 format!("/xrpc/sh.tangled.repo.describeRepo?repoDid={did}"), 1176 Some("ProjectionWarming"), 1177 ), 1178 ( 1179 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet".to_string(), 1180 None, 1181 ), 1182 ( 1183 "/xrpc/sh.tangled.repo.listCollaborators?subject=did:plc:squid".to_string(), 1184 None, 1185 ), 1186 ]; 1187 let w = &world; 1188 stream::iter(cases.iter()) 1189 .for_each(|(path, expected)| async move { 1190 assert_warming(w, path, *expected).await; 1191 }) 1192 .await; 1193} 1194 1195#[tokio::test] 1196async fn branch_tips_render_edge_shapes() { 1197 let world = World::new(); 1198 let (did, work) = seeded(&world, "trochus"); 1199 let bare = world.layout.repo_path(&did).unwrap(); 1200 let bare_str = bare.to_str().unwrap().to_string(); 1201 1202 sh_git(work.path(), &["checkout", "-q", "-b", "side", "HEAD~1"]); 1203 commit_file( 1204 work.path(), 1205 "side.txt", 1206 b"side\n", 1207 "side work", 1208 "2026-06-01T12:34:00+02:00", 1209 ); 1210 sh_git(work.path(), &["checkout", "-q", "main"]); 1211 sh_git_at( 1212 work.path(), 1213 "2026-06-01T12:35:00+02:00", 1214 &["merge", "-q", "--no-ff", "-m", "merge side", "side"], 1215 ); 1216 sh_git(work.path(), &["push", "-q", &bare_str, "main"]); 1217 let first_parent = sh_git(work.path(), &["rev-parse", "HEAD^1"]); 1218 let second_parent = sh_git(work.path(), &["rev-parse", "HEAD^2"]); 1219 1220 let tag_object = sh_git(work.path(), &["rev-parse", "v1.0.0"]); 1221 std::fs::write(bare.join("refs/heads/tagtip"), format!("{tag_object}\n")).unwrap(); 1222 let root_commit = sh_git(work.path(), &["rev-list", "--max-parents=0", "HEAD"]); 1223 std::fs::write(bare.join("refs/heads/roottip"), format!("{root_commit}\n")).unwrap(); 1224 1225 let value = get_json( 1226 &world, 1227 &format!("/xrpc/sh.tangled.repo.branches?repo={did}"), 1228 ) 1229 .await; 1230 let branches = value["branches"].as_array().unwrap(); 1231 assert_eq!(branches.len(), 3); 1232 1233 let branch = |name: &str| { 1234 branches 1235 .iter() 1236 .find(|branch| branch["reference"]["name"] == name) 1237 .unwrap() 1238 }; 1239 let parents = |branch: &serde_json::Value| -> Vec<String> { 1240 branch["commit"]["ParentHashes"] 1241 .as_array() 1242 .unwrap() 1243 .iter() 1244 .map(|parent| { 1245 parent 1246 .as_array() 1247 .unwrap() 1248 .iter() 1249 .map(|byte| format!("{:02x}", byte.as_u64().unwrap())) 1250 .collect() 1251 }) 1252 .collect() 1253 }; 1254 1255 let main = branch("main"); 1256 assert_eq!( 1257 parents(main), 1258 vec![first_parent, second_parent], 1259 "merge tip must report both parents in order" 1260 ); 1261 assert_eq!(main["commit"]["Author"]["Name"], "nel"); 1262 assert!( 1263 parents(branch("roottip")).is_empty(), 1264 "root tip must report no parents" 1265 ); 1266 1267 let tagtip = branch("tagtip"); 1268 assert!( 1269 parents(tagtip).is_empty(), 1270 "a non-commit tip must report no parents" 1271 ); 1272 assert_eq!(tagtip["reference"]["hash"], tag_object.as_str()); 1273 assert_eq!(tagtip["commit"]["Author"]["Name"], ""); 1274 assert_eq!(tagtip["commit"]["Author"]["When"], "0001-01-01T00:00:00Z"); 1275 assert_eq!(tagtip["commit"]["Message"], "release one"); 1276 assert!( 1277 tagtip["commit"]["TreeHash"] 1278 .as_array() 1279 .unwrap() 1280 .iter() 1281 .all(|byte| byte.as_u64() == Some(0)), 1282 "opaque tip has the zero tree hash" 1283 ); 1284} 1285 1286#[tokio::test] 1287async fn a_submodule_path_in_the_tree_is_path_not_found() { 1288 let world = World::new(); 1289 let (did, work) = seeded(&world, "razorclam"); 1290 let bare = world 1291 .layout 1292 .repo_path(&did) 1293 .unwrap() 1294 .to_str() 1295 .unwrap() 1296 .to_string(); 1297 let head = sh_git(work.path(), &["rev-parse", "HEAD"]); 1298 sh_git( 1299 work.path(), 1300 &[ 1301 "update-index", 1302 "--add", 1303 "--cacheinfo", 1304 &format!("160000,{head},vendor/dep"), 1305 ], 1306 ); 1307 sh_git(work.path(), &["commit", "-q", "-m", "add gitlink"]); 1308 sh_git(work.path(), &["push", "-q", &bare, "main"]); 1309 1310 assert_eq!( 1311 get_error( 1312 &world, 1313 &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main&path=vendor/dep"), 1314 ) 1315 .await, 1316 (StatusCode::NOT_FOUND, "PathNotFound".to_string()) 1317 ); 1318} 1319 1320#[tokio::test] 1321async fn a_blob_past_the_derived_serving_limit_is_a_named_error() { 1322 let world = World::with_response_limit(ResponseLimit::new(1024)); 1323 let (did, bare, work) = empty_repo(&world, "auger"); 1324 commit_file( 1325 work.path(), 1326 "big.txt", 1327 "a".repeat(2_000).as_bytes(), 1328 "big file", 1329 "2026-06-01T12:30:00+02:00", 1330 ); 1331 sh_git(work.path(), &["push", "-q", &bare, "main"]); 1332 1333 let (status, error) = get_error( 1334 &world, 1335 &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=big.txt"), 1336 ) 1337 .await; 1338 assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); 1339 assert_eq!( 1340 error, "BlobTooLarge", 1341 "blob limit is reached before the generic response limit" 1342 ); 1343 1344 let (status, _, body) = get( 1345 &world, 1346 &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=big.txt&raw=true"), 1347 ) 1348 .await; 1349 assert_eq!(status, StatusCode::OK, "raw serving keeps full limit"); 1350 assert_eq!(body.len(), 2_000); 1351} 1352 1353#[tokio::test] 1354async fn an_oversized_readme_is_omitted_from_the_tree() { 1355 let world = World::with_response_limit(ResponseLimit::new(2_048)); 1356 let (did, bare, work) = empty_repo(&world, "cowrie"); 1357 commit_file( 1358 work.path(), 1359 "README.md", 1360 format!("# reef\n\n{}\n", "r".repeat(1_000)).as_bytes(), 1361 "huge readme", 1362 "2026-06-01T12:30:00+02:00", 1363 ); 1364 sh_git(work.path(), &["push", "-q", &bare, "main"]); 1365 1366 let value = get_json( 1367 &world, 1368 &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main"), 1369 ) 1370 .await; 1371 assert_eq!(value["files"][0]["name"], "README.md"); 1372 assert_eq!( 1373 value["readme"]["contents"], "", 1374 "readme past the serving limit is omitted instead of failing the whole tree" 1375 ); 1376} 1377 1378#[tokio::test] 1379async fn a_comparison_spanning_too_many_commits_is_refused() { 1380 let world = World::new(); 1381 let (did, work) = seeded(&world, "abalone"); 1382 let bare = world 1383 .layout 1384 .repo_path(&did) 1385 .unwrap() 1386 .to_str() 1387 .unwrap() 1388 .to_string(); 1389 let base = sh_git(work.path(), &["rev-parse", "HEAD"]); 1390 (0..501).for_each(|index| { 1391 sh_git( 1392 work.path(), 1393 &["commit", "-q", "--allow-empty", "-m", &format!("c{index}")], 1394 ); 1395 }); 1396 sh_git(work.path(), &["push", "-q", &bare, "main"]); 1397 let head = sh_git(work.path(), &["rev-parse", "HEAD"]); 1398 1399 let (status, error) = get_error( 1400 &world, 1401 &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={base}&rev2={head}"), 1402 ) 1403 .await; 1404 assert_eq!(status, StatusCode::BAD_REQUEST); 1405 assert_eq!(error, "CompareError"); 1406} 1407 1408#[tokio::test] 1409async fn archive_rejects_traversal_prefixes_and_sanitizes_the_filename() { 1410 let world = World::new(); 1411 let (did, work) = seeded(&world, "cockle"); 1412 let bare = world 1413 .layout 1414 .repo_path(&did) 1415 .unwrap() 1416 .to_str() 1417 .unwrap() 1418 .to_string(); 1419 1420 let (status, error) = get_error( 1421 &world, 1422 &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main&prefix=../evil"), 1423 ) 1424 .await; 1425 assert_eq!(status, StatusCode::BAD_REQUEST); 1426 assert_eq!(error, "InvalidRequest"); 1427 1428 sh_git(work.path(), &["branch", "a\"b"]); 1429 sh_git(work.path(), &["push", "-q", &bare, "refs/heads/a\"b"]); 1430 let (status, headers, _) = get( 1431 &world, 1432 &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=a%22b"), 1433 ) 1434 .await; 1435 assert_eq!(status, StatusCode::OK); 1436 let disposition = headers 1437 .get(header::CONTENT_DISPOSITION) 1438 .unwrap() 1439 .to_str() 1440 .unwrap(); 1441 assert_eq!( 1442 disposition, 1443 format!("attachment; filename=\"{did}-a-b.tar.gz\""), 1444 "quote in the ref name mustn't break the header quoting" 1445 ); 1446} 1447 1448#[tokio::test] 1449async fn an_archive_larger_than_the_configured_limit_is_refused() { 1450 let world = World::with_archive_limit(ArchiveLimit::new(64)); 1451 let (did, _work) = seeded(&world, "murex"); 1452 1453 let (status, error) = get_error( 1454 &world, 1455 &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main"), 1456 ) 1457 .await; 1458 assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); 1459 assert_eq!(error, "RequestTooLarge"); 1460} 1461 1462#[tokio::test] 1463async fn an_oversized_read_response_is_refused() { 1464 let world = World::with_response_limit(ResponseLimit::new(256)); 1465 let (did, _work) = seeded(&world, "clam"); 1466 1467 let (status, error) = get_error( 1468 &world, 1469 &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=main"), 1470 ) 1471 .await; 1472 assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); 1473 assert_eq!(error, "RequestTooLarge"); 1474 1475 let small = get_json( 1476 &world, 1477 &format!("/xrpc/sh.tangled.repo.getDefaultBranch?repo={did}"), 1478 ) 1479 .await; 1480 assert_eq!(small["name"], "main"); 1481} 1482 1483#[tokio::test] 1484async fn languages_omit_files_with_zero_size() { 1485 let world = World::new(); 1486 let (did, bare, work) = empty_repo(&world, "topshell"); 1487 commit_file( 1488 work.path(), 1489 "lib.rs", 1490 b"", 1491 "empty rust file", 1492 "2026-06-01T12:30:00+02:00", 1493 ); 1494 sh_git(work.path(), &["push", "-q", &bare, "main"]); 1495 1496 let value = get_json( 1497 &world, 1498 &format!("/xrpc/sh.tangled.repo.languages?repo={did}&ref=main"), 1499 ) 1500 .await; 1501 assert!( 1502 value["languages"].is_null(), 1503 "zero-byte file mustn't surface as a language with a NaN percentage" 1504 ); 1505 assert!(value.get("totalSize").is_none()); 1506 assert!(value.get("totalFiles").is_none()); 1507} 1508 1509#[tokio::test] 1510async fn a_compare_patch_round_trips_through_merge_check() { 1511 let world = World::new(); 1512 let (_did, main_sha, feature_sha) = seeded_feature_branch(&world, "periwinkle"); 1513 let registered = RepoDid::new("did:plc:periwinklefixture").unwrap(); 1514 1515 let compared = get_json( 1516 &world, 1517 &format!( 1518 "/xrpc/sh.tangled.repo.compare?repo={registered}&rev1={main_sha}&rev2={feature_sha}" 1519 ), 1520 ) 1521 .await; 1522 let patch = compared["patch"].as_str().unwrap(); 1523 1524 let (status, check) = post_json( 1525 &world, 1526 "/xrpc/sh.tangled.repo.mergeCheck", 1527 serde_json::json!({ 1528 "did": OWNER, 1529 "name": "periwinkle", 1530 "branch": "main", 1531 "patch": patch, 1532 }), 1533 ) 1534 .await; 1535 assert_eq!(status, StatusCode::OK); 1536 assert_eq!( 1537 check["is_conflicted"], 1538 serde_json::Value::Bool(false), 1539 "knot's own compare output must pass its own merge check: {check}" 1540 ); 1541 1542 let (status, stale) = post_json( 1543 &world, 1544 "/xrpc/sh.tangled.repo.mergeCheck", 1545 serde_json::json!({ 1546 "did": OWNER, 1547 "name": "periwinkle", 1548 "branch": "feature", 1549 "patch": patch, 1550 }), 1551 ) 1552 .await; 1553 assert_eq!(status, StatusCode::OK); 1554 assert_eq!( 1555 stale["is_conflicted"], 1556 serde_json::Value::Bool(true), 1557 "re-applying an already-landed patch must conflict: {stale}" 1558 ); 1559} 1560 1561#[tokio::test] 1562async fn list_members_pages_in_the_wire_shape() { 1563 let world = World::new(); 1564 world.add_member("did:plc:limpet", OWNER, 1_000); 1565 world.add_member("did:plc:scallop", OWNER, 2_000); 1566 world.add_member("did:plc:whelk", OWNER, 3_000); 1567 1568 let page = get_json( 1569 &world, 1570 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&limit=2", 1571 ) 1572 .await; 1573 let items = page["items"].as_array().unwrap(); 1574 assert_eq!(items.len(), 2, "default order is createdAt descending"); 1575 assert_eq!(items[0]["subject"], "did:plc:whelk"); 1576 assert_eq!(items[0]["addedBy"], OWNER); 1577 assert_eq!(items[0]["createdAt"], "1970-01-01T00:50:00Z"); 1578 assert!( 1579 items[0].get("uri").is_none() && items[0].get("cid").is_none(), 1580 "knot-owned member has no backing record, so uri and cid are omitted" 1581 ); 1582 assert_eq!(items[1]["subject"], "did:plc:scallop"); 1583 1584 let cursor = page["cursor"].as_str().unwrap(); 1585 let next = get_json( 1586 &world, 1587 &format!( 1588 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&limit=2&cursor={cursor}" 1589 ), 1590 ) 1591 .await; 1592 let items = next["items"].as_array().unwrap(); 1593 assert_eq!(items.len(), 1); 1594 assert_eq!(items[0]["subject"], "did:plc:limpet"); 1595 assert!(next.get("cursor").is_none(), "drained list has no cursor"); 1596 1597 let ascending = get_json( 1598 &world, 1599 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&order=asc&limit=1", 1600 ) 1601 .await; 1602 assert_eq!(ascending["items"][0]["subject"], "did:plc:limpet"); 1603} 1604 1605#[tokio::test] 1606async fn list_members_rejects_malformed_params_and_clamps_the_limit() { 1607 let world = World::new(); 1608 world.add_member("did:plc:limpet", OWNER, 1_000); 1609 1610 let queries: &[&str] = &[ 1611 "limit=abc&subject=did:web:knot.nel.pet", 1612 "cursor=notanint&subject=did:web:knot.nel.pet", 1613 "order=ascending&subject=did:web:knot.nel.pet", 1614 "limit=2", 1615 "subject=knot.nel.pet", 1616 ]; 1617 let w = &world; 1618 stream::iter(queries.iter().copied()) 1619 .for_each(|query| async move { 1620 let (status, _) = 1621 get_error(w, &format!("/xrpc/sh.tangled.knot.listMembers?{query}")).await; 1622 assert_eq!(status, StatusCode::BAD_REQUEST, "query {query}"); 1623 }) 1624 .await; 1625 1626 let clamped = get_json( 1627 &world, 1628 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&limit=5000", 1629 ) 1630 .await; 1631 assert_eq!(clamped["items"].as_array().unwrap().len(), 1); 1632} 1633 1634#[tokio::test] 1635async fn list_collaborators_is_scoped_to_the_repo() { 1636 let world = World::new(); 1637 let squid = RepoDid::new("did:plc:squid").unwrap(); 1638 let clam = RepoDid::new("did:plc:clam").unwrap(); 1639 world.layout.create(&squid).unwrap(); 1640 world.layout.create(&clam).unwrap(); 1641 world.register(&squid, "squid"); 1642 world.register(&clam, "clam"); 1643 world.add_collaborator(&squid, "did:plc:lyna", OWNER, 1_000); 1644 world.add_collaborator(&clam, "did:plc:bailey", OWNER, 2_000); 1645 1646 let page = get_json( 1647 &world, 1648 "/xrpc/sh.tangled.repo.listCollaborators?subject=did:plc:squid", 1649 ) 1650 .await; 1651 let items = page["items"].as_array().unwrap(); 1652 assert_eq!(items.len(), 1); 1653 assert_eq!(items[0]["subject"], "did:plc:lyna"); 1654 assert_eq!(items[0]["addedBy"], OWNER); 1655 assert_eq!(items[0]["createdAt"], "1970-01-01T00:16:40Z"); 1656 assert!(items[0].get("uri").is_none() && items[0].get("cid").is_none()); 1657 1658 let unknown = get_json( 1659 &world, 1660 "/xrpc/sh.tangled.repo.listCollaborators?subject=did:plc:unhosted", 1661 ) 1662 .await; 1663 assert!( 1664 unknown["items"].as_array().unwrap().is_empty(), 1665 "unhosted repo has no collaborators, the answer is an empty list" 1666 ); 1667 1668 let (status, _) = get_error( 1669 &world, 1670 "/xrpc/sh.tangled.repo.listCollaborators?subject=notadid", 1671 ) 1672 .await; 1673 assert_eq!(status, StatusCode::BAD_REQUEST); 1674} 1675 1676#[tokio::test] 1677async fn the_subject_tie_break_stays_ascending_in_both_directions() { 1678 let world = World::new(); 1679 world.add_member("did:plc:whelk", OWNER, 1_000); 1680 world.add_member("did:plc:limpet", OWNER, 1_000); 1681 1682 let subjects = |page: &serde_json::Value| -> Vec<String> { 1683 page["items"] 1684 .as_array() 1685 .unwrap() 1686 .iter() 1687 .map(|item| item["subject"].as_str().unwrap().to_string()) 1688 .collect() 1689 }; 1690 1691 let asc = get_json( 1692 &world, 1693 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&order=asc", 1694 ) 1695 .await; 1696 let desc = get_json( 1697 &world, 1698 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&order=desc", 1699 ) 1700 .await; 1701 assert_eq!(subjects(&asc), vec!["did:plc:limpet", "did:plc:whelk"]); 1702 assert_eq!( 1703 subjects(&desc), 1704 vec!["did:plc:limpet", "did:plc:whelk"], 1705 "equal-createdAt entries keep an ascending subject tie-break regardless of sort direction" 1706 ); 1707} 1708 1709#[tokio::test] 1710async fn repo_error_outranks_paging_in_any_param_order() { 1711 let world = World::new(); 1712 let orders: &[&str] = &[ 1713 "/xrpc/sh.tangled.repo.listCollaborators?subject=notadid&limit=abc", 1714 "/xrpc/sh.tangled.repo.listCollaborators?limit=abc&subject=notadid", 1715 ]; 1716 let w = &world; 1717 stream::iter(orders.iter().copied()) 1718 .for_each(|query| async move { 1719 let (_, error) = get_error(w, query).await; 1720 assert_eq!( 1721 error, "InvalidRepo", 1722 "the repo extractor runs before paging by signature position for {query}" 1723 ); 1724 }) 1725 .await; 1726} 1727 1728#[tokio::test] 1729async fn service_metadata_endpoints_answer() { 1730 let world = World::new(); 1731 let wire = get_json(&world, "/xrpc/sh.tangled.knot.version").await; 1732 assert_eq!(wire["version"], "v1.15.0"); 1733 assert_eq!(wire["capabilities"], serde_json::json!(["knot-acl"])); 1734 1735 let owner = get_json(&world, "/xrpc/sh.tangled.owner").await; 1736 assert_eq!(owner["owner"], OWNER); 1737} 1738 1739fn publish_update(world: &World, repo: &str) -> EventCursor { 1740 world.state.events.publish(&GitRefUpdate::new( 1741 RepoDid::new(repo).unwrap(), 1742 Some(OwnerDid::new(OWNER).unwrap()), 1743 AccountDid::new("did:plc:nel").unwrap(), 1744 )) 1745} 1746 1747async fn serve_events(world: &World) -> std::net::SocketAddr { 1748 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); 1749 let addr = listener.local_addr().unwrap(); 1750 let router = world.router.clone(); 1751 tokio::spawn(async move { 1752 axum::serve( 1753 listener, 1754 router.into_make_service_with_connect_info::<std::net::SocketAddr>(), 1755 ) 1756 .await 1757 .unwrap(); 1758 }); 1759 addr 1760} 1761 1762type Ws = 1763 tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>; 1764 1765fn next_event(ws: &mut Ws) -> Pin<Box<dyn Future<Output = serde_json::Value> + '_>> { 1766 Box::pin(async move { 1767 let received = tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()) 1768 .await 1769 .expect("an event arrives within the timeout") 1770 .expect("the stream stays open") 1771 .expect("the frame is readable"); 1772 match received { 1773 tungstenite::Message::Text(text) => serde_json::from_str(text.as_str()).unwrap(), 1774 _ => next_event(ws).await, 1775 } 1776 }) 1777} 1778 1779#[tokio::test] 1780async fn the_events_stream_replays_resumes_and_rejects_bad_cursors() { 1781 let world = World::new(); 1782 let first = publish_update(&world, "did:plc:squid"); 1783 publish_update(&world, "did:plc:anemone"); 1784 let addr = serve_events(&world).await; 1785 1786 let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/events")) 1787 .await 1788 .unwrap(); 1789 let replayed_first = next_event(&mut ws).await; 1790 let replayed_second = next_event(&mut ws).await; 1791 assert_eq!(replayed_first["nsid"], "sh.tangled.git.refUpdate"); 1792 assert_eq!(replayed_first["event"]["repo"], "did:plc:squid"); 1793 assert_eq!(replayed_first["event"]["ownerDid"], OWNER); 1794 assert_eq!(replayed_first["event"]["committerDid"], "did:plc:nel"); 1795 assert_eq!(replayed_first["rkey"].as_str().unwrap().len(), 13); 1796 assert_eq!(replayed_second["event"]["repo"], "did:plc:anemone"); 1797 assert!( 1798 replayed_first["created"].as_i64().unwrap() < replayed_second["created"].as_i64().unwrap() 1799 ); 1800 1801 publish_update(&world, "did:plc:whelk"); 1802 let live = next_event(&mut ws).await; 1803 assert_eq!(live["event"]["repo"], "did:plc:whelk"); 1804 1805 let (mut resumed, _) = 1806 tokio_tungstenite::connect_async(format!("ws://{addr}/events?cursor={}", first.get())) 1807 .await 1808 .unwrap(); 1809 let resumed_event = next_event(&mut resumed).await; 1810 assert_eq!( 1811 resumed_event["event"]["repo"], "did:plc:anemone", 1812 "a cursor resumes past the event it names" 1813 ); 1814 1815 let (mut garbled, _) = 1816 tokio_tungstenite::connect_async(format!("ws://{addr}/events?cursor=banana")) 1817 .await 1818 .unwrap(); 1819 let replayed = next_event(&mut garbled).await; 1820 assert_eq!( 1821 replayed["event"]["repo"], "did:plc:squid", 1822 "a garbled cursor replays from the start" 1823 ); 1824} 1825 1826fn refused<T>(result: Result<T, tungstenite::Error>) { 1827 match result { 1828 Err(tungstenite::Error::Http(response)) => { 1829 assert_eq!(response.status().as_u16(), 503); 1830 } 1831 Err(other) => panic!("expected an http refusal: {other}"), 1832 Ok(_) => panic!("a subscriber past the limit connected"), 1833 } 1834} 1835 1836#[tokio::test] 1837async fn a_subscriber_beyond_the_events_limit_is_refused() { 1838 let world = World::new(); 1839 let addr = serve_events(&world).await; 1840 let saturated: Vec<_> = (0..16u8) 1841 .map(|octet| { 1842 world 1843 .state 1844 .subscriber_gate 1845 .try_admit(std::net::IpAddr::V4(std::net::Ipv4Addr::new( 1846 10, 0, 0, octet, 1847 ))) 1848 .expect("distinct peers fill the global limit") 1849 }) 1850 .collect(); 1851 refused(tokio_tungstenite::connect_async(format!("ws://{addr}/events")).await); 1852 drop(saturated); 1853} 1854 1855#[tokio::test] 1856async fn a_single_peer_cannot_monopolize_the_events_stream() { 1857 let world = World::new(); 1858 let addr = serve_events(&world).await; 1859 let held: Vec<_> = stream::iter(0..4) 1860 .then(|_| async { 1861 tokio_tungstenite::connect_async(format!("ws://{addr}/events")) 1862 .await 1863 .expect("a connection within the per-peer limit is admitted") 1864 .0 1865 }) 1866 .collect() 1867 .await; 1868 refused(tokio_tungstenite::connect_async(format!("ws://{addr}/events")).await); 1869 drop(held); 1870} 1871 1872#[tokio::test] 1873async fn set_default_branch_resolves_an_at_uri_repo_and_an_existing_branch() { 1874 let world = World::new(); 1875 let (did, work) = seeded(&world, "coral"); 1876 let bare = world.layout.repo_path(&did).unwrap(); 1877 sh_git(work.path(), &["branch", "release", "main"]); 1878 sh_git( 1879 work.path(), 1880 &["push", "-q", bare.to_str().unwrap(), "refs/heads/release"], 1881 ); 1882 1883 let (status, _) = post_authed( 1884 &world, 1885 "/xrpc/sh.tangled.repo.setDefaultBranch", 1886 OWNER, 1887 serde_json::json!({ 1888 "repo": format!("at://{OWNER}/sh.tangled.repo/coral"), 1889 "defaultBranch": "release", 1890 }), 1891 ) 1892 .await; 1893 assert_eq!(status, StatusCode::OK); 1894 1895 let repo = world.layout.open(&did).unwrap(); 1896 assert_eq!( 1897 repo.default_branch().unwrap().as_str(), 1898 "refs/heads/release", 1899 "the default head moved to the requested branch" 1900 ); 1901} 1902 1903#[tokio::test] 1904async fn delete_branch_removes_a_non_default_branch_then_reports_it_gone() { 1905 let world = World::new(); 1906 let (did, work) = seeded(&world, "kelp"); 1907 let bare = world.layout.repo_path(&did).unwrap(); 1908 sh_git(work.path(), &["branch", "feature", "main"]); 1909 sh_git( 1910 work.path(), 1911 &["push", "-q", bare.to_str().unwrap(), "refs/heads/feature"], 1912 ); 1913 1914 let at = format!("at://{OWNER}/sh.tangled.repo/kelp"); 1915 let (status, _) = post_authed( 1916 &world, 1917 "/xrpc/sh.tangled.repo.deleteBranch", 1918 OWNER, 1919 serde_json::json!({ "repo": at, "branch": "feature" }), 1920 ) 1921 .await; 1922 assert_eq!(status, StatusCode::OK); 1923 1924 let (status, body) = post_authed( 1925 &world, 1926 "/xrpc/sh.tangled.repo.deleteBranch", 1927 OWNER, 1928 serde_json::json!({ "repo": at, "branch": "feature" }), 1929 ) 1930 .await; 1931 assert_eq!(status, StatusCode::NOT_FOUND, "second delete: {body}"); 1932} 1933 1934#[tokio::test] 1935async fn bad_post_bodies_are_invalid_request() { 1936 let world = World::new(); 1937 let (_kelp, _wk) = seeded(&world, "kelp"); 1938 let (_barnacle, _wb) = seeded(&world, "barnacle"); 1939 1940 let cases: &[(&str, serde_json::Value)] = &[ 1941 ( 1942 "/xrpc/sh.tangled.repo.setDefaultBranch", 1943 serde_json::json!({ "repo": "not-an-at-uri", "defaultBranch": "main" }), 1944 ), 1945 ( 1946 "/xrpc/sh.tangled.repo.deleteBranch", 1947 serde_json::json!({ 1948 "repo": format!("at://{OWNER}/sh.tangled.repo/kelp"), 1949 "branch": "bad branch", 1950 }), 1951 ), 1952 ( 1953 "/xrpc/sh.tangled.repo.forkSync", 1954 serde_json::json!({ "did": OWNER, "name": "barnacle", "branch": "bad branch" }), 1955 ), 1956 ( 1957 "/xrpc/sh.tangled.repo.hiddenRef", 1958 serde_json::json!({ "repo": "nope", "forkRef": "feature", "remoteRef": "main" }), 1959 ), 1960 ]; 1961 let w = &world; 1962 stream::iter(cases) 1963 .for_each(|(path, value)| async move { 1964 assert_post_rejected(w, path, OWNER, value.clone()).await; 1965 }) 1966 .await; 1967} 1968 1969#[tokio::test] 1970async fn merge_applies_a_plain_patch_under_the_supplied_author() { 1971 let world = World::new(); 1972 let (_did, main_sha, feature_sha) = seeded_feature_branch(&world, "mussel"); 1973 let registered = RepoDid::new("did:plc:musselfixture").unwrap(); 1974 1975 let compared = get_json( 1976 &world, 1977 &format!( 1978 "/xrpc/sh.tangled.repo.compare?repo={registered}&rev1={main_sha}&rev2={feature_sha}" 1979 ), 1980 ) 1981 .await; 1982 let patch = compared["combined_patch_raw"].as_str().unwrap().to_string(); 1983 1984 let (status, body) = post_authed( 1985 &world, 1986 "/xrpc/sh.tangled.repo.merge", 1987 OWNER, 1988 serde_json::json!({ 1989 "did": OWNER, 1990 "name": "mussel", 1991 "branch": "main", 1992 "patch": patch, 1993 "authorName": "Teq", 1994 "authorEmail": "teq@nel.pet", 1995 "commitMessage": "merged kelp", 1996 }), 1997 ) 1998 .await; 1999 assert_eq!(status, StatusCode::OK, "merge failed: {body}"); 2000 2001 let log = get_json( 2002 &world, 2003 &format!("/xrpc/sh.tangled.repo.log?repo={registered}&ref=main"), 2004 ) 2005 .await; 2006 let top = &log["commits"][0]; 2007 assert_eq!( 2008 top["author"]["Name"], "Teq", 2009 "the supplied author rode through" 2010 ); 2011 assert!( 2012 top["message"].as_str().unwrap().contains("merged kelp"), 2013 "the supplied commit message rode through: {}", 2014 top["message"] 2015 ); 2016} 2017 2018#[tokio::test] 2019async fn create_mints_a_did_plc_repo_with_the_requested_default_branch() { 2020 let world = World::new(); 2021 world.add_member(OWNER, OWNER, 1_000); 2022 let (status, body) = post_authed( 2023 &world, 2024 "/xrpc/sh.tangled.repo.create", 2025 OWNER, 2026 serde_json::json!({ "rkey": "squidkey", "name": "squid", "defaultBranch": "trunk" }), 2027 ) 2028 .await; 2029 assert_eq!(status, StatusCode::OK, "create failed: {body}"); 2030 let repo_did = body["repoDid"].as_str().unwrap(); 2031 assert!( 2032 repo_did.starts_with("did:plc:"), 2033 "minted a did:plc: {repo_did}" 2034 ); 2035 let did = RepoDid::new(repo_did).unwrap(); 2036 let repo = world.layout.open(&did).unwrap(); 2037 assert_eq!( 2038 repo.default_branch().unwrap().as_str(), 2039 "refs/heads/trunk", 2040 "the requested default branch became HEAD" 2041 ); 2042}