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
64 kB 1971 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 the_cob_ref_namespace_is_invisible_across_every_read() { 898 let world = World::new(); 899 let (did, work) = seeded(&world, "anemone"); 900 let head = sh_git(work.path(), &["rev-parse", "HEAD"]); 901 let bare = world.layout.repo_path(&did).unwrap(); 902 let cob = "refs/cobs/sh.tangled.repo.collaborator/x"; 903 sh_git(bare.as_path(), &["update-ref", cob, &head]); 904 905 let value = get_json(&world, &format!("/xrpc/sh.tangled.git.listRefs?repo={did}")).await; 906 assert!( 907 ref_names(&value, "refs") 908 .iter() 909 .all(|name| !name.starts_with("refs/cobs/")), 910 "reserved cob ref leaked into listRefs" 911 ); 912 913 let w = &world; 914 let d = &did; 915 let named_routes: &[(&str, &str)] = &[ 916 ("log", ""), 917 ("tree", ""), 918 ("blob", "&path=README.md"), 919 ("diff", ""), 920 ("archive", ""), 921 ("languages", ""), 922 ]; 923 stream::iter(named_routes) 924 .for_each(|&(route, suffix)| async move { 925 let (status, _) = get_error( 926 w, 927 &format!("/xrpc/sh.tangled.repo.{route}?repo={d}&ref={cob}{suffix}"), 928 ) 929 .await; 930 assert_eq!( 931 status, 932 StatusCode::NOT_FOUND, 933 "{route} mustn't resolve reserved cobs ref" 934 ); 935 }) 936 .await; 937 let (status, _) = get_error( 938 &world, 939 &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=cobs/sh.tangled.repo.collaborator/x"), 940 ) 941 .await; 942 assert_eq!( 943 status, 944 StatusCode::NOT_FOUND, 945 "reserved namespace shorthand mustn't resolve either" 946 ); 947 let (status, _) = get_error( 948 &world, 949 &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={cob}&rev2={head}"), 950 ) 951 .await; 952 assert_eq!(status, StatusCode::BAD_REQUEST); 953 954 commit_file( 955 work.path(), 956 "secret.txt", 957 b"hidden\n", 958 "secret", 959 "2026-06-01T12:40:00+02:00", 960 ); 961 let hidden = sh_git(work.path(), &["rev-parse", "HEAD"]); 962 sh_git( 963 work.path(), 964 &[ 965 "push", 966 "-q", 967 bare.to_str().unwrap(), 968 "HEAD:refs/cobs/sh.tangled.repo.collaborator/secret", 969 ], 970 ); 971 972 let hidden_ref = &hidden; 973 let hidden_routes: &[(&str, &str)] = &[ 974 ("log", ""), 975 ("tree", "&path=secret.txt"), 976 ("diff", ""), 977 ("archive", ""), 978 ("languages", ""), 979 ]; 980 stream::iter(hidden_routes) 981 .for_each(|&(route, suffix)| async move { 982 let (status, _) = get_error( 983 w, 984 &format!("/xrpc/sh.tangled.repo.{route}?repo={d}&ref={hidden_ref}{suffix}"), 985 ) 986 .await; 987 assert_eq!( 988 status, 989 StatusCode::NOT_FOUND, 990 "{route} mustn't serve a commit reachable only through cob ref" 991 ); 992 }) 993 .await; 994 let (status, _) = get_error( 995 &world, 996 &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref={hidden}&path=secret.txt"), 997 ) 998 .await; 999 assert_eq!(status, StatusCode::NOT_FOUND); 1000 let (status, error) = get_error( 1001 &world, 1002 &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={head}&rev2={hidden}"), 1003 ) 1004 .await; 1005 assert_eq!(status, StatusCode::BAD_REQUEST); 1006 assert_eq!(error, "RevisionNotFound"); 1007 1008 let still_public = get_json( 1009 &world, 1010 &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref={head}"), 1011 ) 1012 .await; 1013 assert_eq!(still_public["total"].as_i64(), Some(4)); 1014} 1015 1016#[tokio::test] 1017async fn the_list_reads_reject_malformed_paging_params() { 1018 let world = World::new(); 1019 let (did, _work) = seeded(&world, "barnacle"); 1020 1021 let queries: Vec<String> = vec![ 1022 format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=abc"), 1023 format!("/xrpc/sh.tangled.git.listRefs?repo={did}&cursor=notanint"), 1024 "/xrpc/sh.tangled.sync.listRepos?limit=abc".to_string(), 1025 "/xrpc/sh.tangled.sync.listRepos?cursor=notanint".to_string(), 1026 "/xrpc/sh.tangled.sync.listRepos?order=sideways".to_string(), 1027 ]; 1028 let w = &world; 1029 stream::iter(queries.iter()) 1030 .for_each(|query| async move { 1031 let (status, error) = get_error(w, query).await; 1032 assert_eq!(status, StatusCode::BAD_REQUEST, "query {query}"); 1033 assert_eq!(error, "InvalidRequest", "query {query}"); 1034 }) 1035 .await; 1036 1037 let clamped = get_json( 1038 &world, 1039 &format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=5000"), 1040 ) 1041 .await; 1042 assert_eq!( 1043 ref_names(&clamped, "refs").len(), 1044 3, 1045 "an oversize limit clamps to the max instead of erroring" 1046 ); 1047} 1048 1049#[tokio::test] 1050async fn list_repos_lists_hosted_repos_with_order_and_pagination() { 1051 let world = World::new(); 1052 let (mussel, _a) = seeded(&world, "mussel"); 1053 let (nautilus, _b) = seeded(&world, "nautilus"); 1054 let (scallop, _c) = seeded(&world, "scallop"); 1055 1056 let desc = get_json(&world, "/xrpc/sh.tangled.sync.listRepos").await; 1057 assert_eq!( 1058 repo_dids(&desc), 1059 vec![ 1060 scallop.as_str().to_string(), 1061 nautilus.as_str().to_string(), 1062 mussel.as_str().to_string(), 1063 ] 1064 ); 1065 assert_eq!(desc["repos"][0]["status"], "active"); 1066 assert_eq!(desc["repos"][0]["defaultBranch"]["ref"], "refs/heads/main"); 1067 1068 let asc = get_json(&world, "/xrpc/sh.tangled.sync.listRepos?order=asc").await; 1069 assert_eq!( 1070 repo_dids(&asc), 1071 vec![ 1072 mussel.as_str().to_string(), 1073 nautilus.as_str().to_string(), 1074 scallop.as_str().to_string(), 1075 ] 1076 ); 1077 1078 let page = get_json(&world, "/xrpc/sh.tangled.sync.listRepos?order=asc&limit=2").await; 1079 assert_eq!(page["repos"].as_array().unwrap().len(), 2); 1080 let cursor = page["cursor"].as_str().unwrap().to_string(); 1081 let rest = get_json( 1082 &world, 1083 &format!("/xrpc/sh.tangled.sync.listRepos?order=asc&limit=2&cursor={cursor}"), 1084 ) 1085 .await; 1086 assert_eq!(repo_dids(&rest), vec![scallop.as_str().to_string()]); 1087 assert!(rest["cursor"].is_null()); 1088} 1089 1090#[tokio::test] 1091async fn every_projection_read_fails_closed_while_warming() { 1092 let world = World::warming(); 1093 let did = RepoDid::new("did:plc:limpetfixture").unwrap(); 1094 let cases: Vec<(String, Option<&str>)> = vec![ 1095 ( 1096 "/xrpc/sh.tangled.sync.listRepos".to_string(), 1097 Some("ProjectionWarming"), 1098 ), 1099 ( 1100 format!("/xrpc/sh.tangled.repo.getDefaultBranch?repo={did}"), 1101 Some("ProjectionWarming"), 1102 ), 1103 ( 1104 format!("/xrpc/sh.tangled.repo.describeRepo?repoDid={did}"), 1105 Some("ProjectionWarming"), 1106 ), 1107 ( 1108 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet".to_string(), 1109 None, 1110 ), 1111 ( 1112 "/xrpc/sh.tangled.repo.listCollaborators?subject=did:plc:squid".to_string(), 1113 None, 1114 ), 1115 ]; 1116 let w = &world; 1117 stream::iter(cases.iter()) 1118 .for_each(|(path, expected)| async move { 1119 assert_warming(w, path, *expected).await; 1120 }) 1121 .await; 1122} 1123 1124#[tokio::test] 1125async fn branch_tips_render_edge_shapes() { 1126 let world = World::new(); 1127 let (did, work) = seeded(&world, "trochus"); 1128 let bare = world.layout.repo_path(&did).unwrap(); 1129 let bare_str = bare.to_str().unwrap().to_string(); 1130 1131 sh_git(work.path(), &["checkout", "-q", "-b", "side", "HEAD~1"]); 1132 commit_file( 1133 work.path(), 1134 "side.txt", 1135 b"side\n", 1136 "side work", 1137 "2026-06-01T12:34:00+02:00", 1138 ); 1139 sh_git(work.path(), &["checkout", "-q", "main"]); 1140 sh_git_at( 1141 work.path(), 1142 "2026-06-01T12:35:00+02:00", 1143 &["merge", "-q", "--no-ff", "-m", "merge side", "side"], 1144 ); 1145 sh_git(work.path(), &["push", "-q", &bare_str, "main"]); 1146 let first_parent = sh_git(work.path(), &["rev-parse", "HEAD^1"]); 1147 let second_parent = sh_git(work.path(), &["rev-parse", "HEAD^2"]); 1148 1149 let tag_object = sh_git(work.path(), &["rev-parse", "v1.0.0"]); 1150 std::fs::write(bare.join("refs/heads/tagtip"), format!("{tag_object}\n")).unwrap(); 1151 let root_commit = sh_git(work.path(), &["rev-list", "--max-parents=0", "HEAD"]); 1152 std::fs::write(bare.join("refs/heads/roottip"), format!("{root_commit}\n")).unwrap(); 1153 1154 let value = get_json( 1155 &world, 1156 &format!("/xrpc/sh.tangled.repo.branches?repo={did}"), 1157 ) 1158 .await; 1159 let branches = value["branches"].as_array().unwrap(); 1160 assert_eq!(branches.len(), 3); 1161 1162 let branch = |name: &str| { 1163 branches 1164 .iter() 1165 .find(|branch| branch["reference"]["name"] == name) 1166 .unwrap() 1167 }; 1168 let parents = |branch: &serde_json::Value| -> Vec<String> { 1169 branch["commit"]["ParentHashes"] 1170 .as_array() 1171 .unwrap() 1172 .iter() 1173 .map(|parent| { 1174 parent 1175 .as_array() 1176 .unwrap() 1177 .iter() 1178 .map(|byte| format!("{:02x}", byte.as_u64().unwrap())) 1179 .collect() 1180 }) 1181 .collect() 1182 }; 1183 1184 let main = branch("main"); 1185 assert_eq!( 1186 parents(main), 1187 vec![first_parent, second_parent], 1188 "merge tip must report both parents in order" 1189 ); 1190 assert_eq!(main["commit"]["Author"]["Name"], "nel"); 1191 assert!( 1192 parents(branch("roottip")).is_empty(), 1193 "root tip must report no parents" 1194 ); 1195 1196 let tagtip = branch("tagtip"); 1197 assert!( 1198 parents(tagtip).is_empty(), 1199 "a non-commit tip must report no parents" 1200 ); 1201 assert_eq!(tagtip["reference"]["hash"], tag_object.as_str()); 1202 assert_eq!(tagtip["commit"]["Author"]["Name"], ""); 1203 assert_eq!(tagtip["commit"]["Author"]["When"], "0001-01-01T00:00:00Z"); 1204 assert_eq!(tagtip["commit"]["Message"], "release one"); 1205 assert!( 1206 tagtip["commit"]["TreeHash"] 1207 .as_array() 1208 .unwrap() 1209 .iter() 1210 .all(|byte| byte.as_u64() == Some(0)), 1211 "opaque tip has the zero tree hash" 1212 ); 1213} 1214 1215#[tokio::test] 1216async fn a_submodule_path_in_the_tree_is_path_not_found() { 1217 let world = World::new(); 1218 let (did, work) = seeded(&world, "razorclam"); 1219 let bare = world 1220 .layout 1221 .repo_path(&did) 1222 .unwrap() 1223 .to_str() 1224 .unwrap() 1225 .to_string(); 1226 let head = sh_git(work.path(), &["rev-parse", "HEAD"]); 1227 sh_git( 1228 work.path(), 1229 &[ 1230 "update-index", 1231 "--add", 1232 "--cacheinfo", 1233 &format!("160000,{head},vendor/dep"), 1234 ], 1235 ); 1236 sh_git(work.path(), &["commit", "-q", "-m", "add gitlink"]); 1237 sh_git(work.path(), &["push", "-q", &bare, "main"]); 1238 1239 assert_eq!( 1240 get_error( 1241 &world, 1242 &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main&path=vendor/dep"), 1243 ) 1244 .await, 1245 (StatusCode::NOT_FOUND, "PathNotFound".to_string()) 1246 ); 1247} 1248 1249#[tokio::test] 1250async fn a_blob_past_the_derived_serving_limit_is_a_named_error() { 1251 let world = World::with_response_limit(ResponseLimit::new(1024)); 1252 let (did, bare, work) = empty_repo(&world, "auger"); 1253 commit_file( 1254 work.path(), 1255 "big.txt", 1256 "a".repeat(2_000).as_bytes(), 1257 "big file", 1258 "2026-06-01T12:30:00+02:00", 1259 ); 1260 sh_git(work.path(), &["push", "-q", &bare, "main"]); 1261 1262 let (status, error) = get_error( 1263 &world, 1264 &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=big.txt"), 1265 ) 1266 .await; 1267 assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); 1268 assert_eq!( 1269 error, "BlobTooLarge", 1270 "blob limit is reached before the generic response limit" 1271 ); 1272 1273 let (status, _, body) = get( 1274 &world, 1275 &format!("/xrpc/sh.tangled.repo.blob?repo={did}&ref=main&path=big.txt&raw=true"), 1276 ) 1277 .await; 1278 assert_eq!(status, StatusCode::OK, "raw serving keeps full limit"); 1279 assert_eq!(body.len(), 2_000); 1280} 1281 1282#[tokio::test] 1283async fn an_oversized_readme_is_omitted_from_the_tree() { 1284 let world = World::with_response_limit(ResponseLimit::new(2_048)); 1285 let (did, bare, work) = empty_repo(&world, "cowrie"); 1286 commit_file( 1287 work.path(), 1288 "README.md", 1289 format!("# reef\n\n{}\n", "r".repeat(1_000)).as_bytes(), 1290 "huge readme", 1291 "2026-06-01T12:30:00+02:00", 1292 ); 1293 sh_git(work.path(), &["push", "-q", &bare, "main"]); 1294 1295 let value = get_json( 1296 &world, 1297 &format!("/xrpc/sh.tangled.repo.tree?repo={did}&ref=main"), 1298 ) 1299 .await; 1300 assert_eq!(value["files"][0]["name"], "README.md"); 1301 assert_eq!( 1302 value["readme"]["contents"], "", 1303 "readme past the serving limit is omitted instead of failing the whole tree" 1304 ); 1305} 1306 1307#[tokio::test] 1308async fn a_comparison_spanning_too_many_commits_is_refused() { 1309 let world = World::new(); 1310 let (did, work) = seeded(&world, "abalone"); 1311 let bare = world 1312 .layout 1313 .repo_path(&did) 1314 .unwrap() 1315 .to_str() 1316 .unwrap() 1317 .to_string(); 1318 let base = sh_git(work.path(), &["rev-parse", "HEAD"]); 1319 (0..501).for_each(|index| { 1320 sh_git( 1321 work.path(), 1322 &["commit", "-q", "--allow-empty", "-m", &format!("c{index}")], 1323 ); 1324 }); 1325 sh_git(work.path(), &["push", "-q", &bare, "main"]); 1326 let head = sh_git(work.path(), &["rev-parse", "HEAD"]); 1327 1328 let (status, error) = get_error( 1329 &world, 1330 &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={base}&rev2={head}"), 1331 ) 1332 .await; 1333 assert_eq!(status, StatusCode::BAD_REQUEST); 1334 assert_eq!(error, "CompareError"); 1335} 1336 1337#[tokio::test] 1338async fn archive_rejects_traversal_prefixes_and_sanitizes_the_filename() { 1339 let world = World::new(); 1340 let (did, work) = seeded(&world, "cockle"); 1341 let bare = world 1342 .layout 1343 .repo_path(&did) 1344 .unwrap() 1345 .to_str() 1346 .unwrap() 1347 .to_string(); 1348 1349 let (status, error) = get_error( 1350 &world, 1351 &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main&prefix=../evil"), 1352 ) 1353 .await; 1354 assert_eq!(status, StatusCode::BAD_REQUEST); 1355 assert_eq!(error, "InvalidRequest"); 1356 1357 sh_git(work.path(), &["branch", "a\"b"]); 1358 sh_git(work.path(), &["push", "-q", &bare, "refs/heads/a\"b"]); 1359 let (status, headers, _) = get( 1360 &world, 1361 &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=a%22b"), 1362 ) 1363 .await; 1364 assert_eq!(status, StatusCode::OK); 1365 let disposition = headers 1366 .get(header::CONTENT_DISPOSITION) 1367 .unwrap() 1368 .to_str() 1369 .unwrap(); 1370 assert_eq!( 1371 disposition, 1372 format!("attachment; filename=\"{did}-a-b.tar.gz\""), 1373 "quote in the ref name mustn't break the header quoting" 1374 ); 1375} 1376 1377#[tokio::test] 1378async fn an_archive_larger_than_the_configured_limit_is_refused() { 1379 let world = World::with_archive_limit(ArchiveLimit::new(64)); 1380 let (did, _work) = seeded(&world, "murex"); 1381 1382 let (status, error) = get_error( 1383 &world, 1384 &format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main"), 1385 ) 1386 .await; 1387 assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); 1388 assert_eq!(error, "RequestTooLarge"); 1389} 1390 1391#[tokio::test] 1392async fn an_oversized_read_response_is_refused() { 1393 let world = World::with_response_limit(ResponseLimit::new(256)); 1394 let (did, _work) = seeded(&world, "clam"); 1395 1396 let (status, error) = get_error( 1397 &world, 1398 &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=main"), 1399 ) 1400 .await; 1401 assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); 1402 assert_eq!(error, "RequestTooLarge"); 1403 1404 let small = get_json( 1405 &world, 1406 &format!("/xrpc/sh.tangled.repo.getDefaultBranch?repo={did}"), 1407 ) 1408 .await; 1409 assert_eq!(small["name"], "main"); 1410} 1411 1412#[tokio::test] 1413async fn languages_omit_files_with_zero_size() { 1414 let world = World::new(); 1415 let (did, bare, work) = empty_repo(&world, "topshell"); 1416 commit_file( 1417 work.path(), 1418 "lib.rs", 1419 b"", 1420 "empty rust file", 1421 "2026-06-01T12:30:00+02:00", 1422 ); 1423 sh_git(work.path(), &["push", "-q", &bare, "main"]); 1424 1425 let value = get_json( 1426 &world, 1427 &format!("/xrpc/sh.tangled.repo.languages?repo={did}&ref=main"), 1428 ) 1429 .await; 1430 assert!( 1431 value["languages"].is_null(), 1432 "zero-byte file mustn't surface as a language with a NaN percentage" 1433 ); 1434 assert!(value.get("totalSize").is_none()); 1435 assert!(value.get("totalFiles").is_none()); 1436} 1437 1438#[tokio::test] 1439async fn a_compare_patch_round_trips_through_merge_check() { 1440 let world = World::new(); 1441 let (_did, main_sha, feature_sha) = seeded_feature_branch(&world, "periwinkle"); 1442 let registered = RepoDid::new("did:plc:periwinklefixture").unwrap(); 1443 1444 let compared = get_json( 1445 &world, 1446 &format!( 1447 "/xrpc/sh.tangled.repo.compare?repo={registered}&rev1={main_sha}&rev2={feature_sha}" 1448 ), 1449 ) 1450 .await; 1451 let patch = compared["patch"].as_str().unwrap(); 1452 1453 let (status, check) = post_json( 1454 &world, 1455 "/xrpc/sh.tangled.repo.mergeCheck", 1456 serde_json::json!({ 1457 "did": OWNER, 1458 "name": "periwinkle", 1459 "branch": "main", 1460 "patch": patch, 1461 }), 1462 ) 1463 .await; 1464 assert_eq!(status, StatusCode::OK); 1465 assert_eq!( 1466 check["is_conflicted"], 1467 serde_json::Value::Bool(false), 1468 "knot's own compare output must pass its own merge check: {check}" 1469 ); 1470 1471 let (status, stale) = post_json( 1472 &world, 1473 "/xrpc/sh.tangled.repo.mergeCheck", 1474 serde_json::json!({ 1475 "did": OWNER, 1476 "name": "periwinkle", 1477 "branch": "feature", 1478 "patch": patch, 1479 }), 1480 ) 1481 .await; 1482 assert_eq!(status, StatusCode::OK); 1483 assert_eq!( 1484 stale["is_conflicted"], 1485 serde_json::Value::Bool(true), 1486 "re-applying an already-landed patch must conflict: {stale}" 1487 ); 1488} 1489 1490#[tokio::test] 1491async fn list_members_pages_in_the_wire_shape() { 1492 let world = World::new(); 1493 world.add_member("did:plc:limpet", OWNER, 1_000); 1494 world.add_member("did:plc:scallop", OWNER, 2_000); 1495 world.add_member("did:plc:whelk", OWNER, 3_000); 1496 1497 let page = get_json( 1498 &world, 1499 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&limit=2", 1500 ) 1501 .await; 1502 let items = page["items"].as_array().unwrap(); 1503 assert_eq!(items.len(), 2, "default order is createdAt descending"); 1504 assert_eq!(items[0]["subject"], "did:plc:whelk"); 1505 assert_eq!(items[0]["addedBy"], OWNER); 1506 assert_eq!(items[0]["createdAt"], "1970-01-01T00:50:00Z"); 1507 assert!( 1508 items[0].get("uri").is_none() && items[0].get("cid").is_none(), 1509 "knot-owned member has no backing record, so uri and cid are omitted" 1510 ); 1511 assert_eq!(items[1]["subject"], "did:plc:scallop"); 1512 1513 let cursor = page["cursor"].as_str().unwrap(); 1514 let next = get_json( 1515 &world, 1516 &format!( 1517 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&limit=2&cursor={cursor}" 1518 ), 1519 ) 1520 .await; 1521 let items = next["items"].as_array().unwrap(); 1522 assert_eq!(items.len(), 1); 1523 assert_eq!(items[0]["subject"], "did:plc:limpet"); 1524 assert!(next.get("cursor").is_none(), "drained list has no cursor"); 1525 1526 let ascending = get_json( 1527 &world, 1528 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&order=asc&limit=1", 1529 ) 1530 .await; 1531 assert_eq!(ascending["items"][0]["subject"], "did:plc:limpet"); 1532} 1533 1534#[tokio::test] 1535async fn list_members_rejects_malformed_params_and_clamps_the_limit() { 1536 let world = World::new(); 1537 world.add_member("did:plc:limpet", OWNER, 1_000); 1538 1539 let queries: &[&str] = &[ 1540 "limit=abc&subject=did:web:knot.nel.pet", 1541 "cursor=notanint&subject=did:web:knot.nel.pet", 1542 "order=ascending&subject=did:web:knot.nel.pet", 1543 "limit=2", 1544 "subject=knot.nel.pet", 1545 ]; 1546 let w = &world; 1547 stream::iter(queries.iter().copied()) 1548 .for_each(|query| async move { 1549 let (status, _) = 1550 get_error(w, &format!("/xrpc/sh.tangled.knot.listMembers?{query}")).await; 1551 assert_eq!(status, StatusCode::BAD_REQUEST, "query {query}"); 1552 }) 1553 .await; 1554 1555 let clamped = get_json( 1556 &world, 1557 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&limit=5000", 1558 ) 1559 .await; 1560 assert_eq!(clamped["items"].as_array().unwrap().len(), 1); 1561} 1562 1563#[tokio::test] 1564async fn list_collaborators_is_scoped_to_the_repo() { 1565 let world = World::new(); 1566 let squid = RepoDid::new("did:plc:squid").unwrap(); 1567 let clam = RepoDid::new("did:plc:clam").unwrap(); 1568 world.layout.create(&squid).unwrap(); 1569 world.layout.create(&clam).unwrap(); 1570 world.register(&squid, "squid"); 1571 world.register(&clam, "clam"); 1572 world.add_collaborator(&squid, "did:plc:lyna", OWNER, 1_000); 1573 world.add_collaborator(&clam, "did:plc:bailey", OWNER, 2_000); 1574 1575 let page = get_json( 1576 &world, 1577 "/xrpc/sh.tangled.repo.listCollaborators?subject=did:plc:squid", 1578 ) 1579 .await; 1580 let items = page["items"].as_array().unwrap(); 1581 assert_eq!(items.len(), 1); 1582 assert_eq!(items[0]["subject"], "did:plc:lyna"); 1583 assert_eq!(items[0]["addedBy"], OWNER); 1584 assert_eq!(items[0]["createdAt"], "1970-01-01T00:16:40Z"); 1585 assert!(items[0].get("uri").is_none() && items[0].get("cid").is_none()); 1586 1587 let unknown = get_json( 1588 &world, 1589 "/xrpc/sh.tangled.repo.listCollaborators?subject=did:plc:unhosted", 1590 ) 1591 .await; 1592 assert!( 1593 unknown["items"].as_array().unwrap().is_empty(), 1594 "unhosted repo has no collaborators, the answer is an empty list" 1595 ); 1596 1597 let (status, _) = get_error( 1598 &world, 1599 "/xrpc/sh.tangled.repo.listCollaborators?subject=notadid", 1600 ) 1601 .await; 1602 assert_eq!(status, StatusCode::BAD_REQUEST); 1603} 1604 1605#[tokio::test] 1606async fn the_subject_tie_break_stays_ascending_in_both_directions() { 1607 let world = World::new(); 1608 world.add_member("did:plc:whelk", OWNER, 1_000); 1609 world.add_member("did:plc:limpet", OWNER, 1_000); 1610 1611 let subjects = |page: &serde_json::Value| -> Vec<String> { 1612 page["items"] 1613 .as_array() 1614 .unwrap() 1615 .iter() 1616 .map(|item| item["subject"].as_str().unwrap().to_string()) 1617 .collect() 1618 }; 1619 1620 let asc = get_json( 1621 &world, 1622 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&order=asc", 1623 ) 1624 .await; 1625 let desc = get_json( 1626 &world, 1627 "/xrpc/sh.tangled.knot.listMembers?subject=did:web:knot.nel.pet&order=desc", 1628 ) 1629 .await; 1630 assert_eq!(subjects(&asc), vec!["did:plc:limpet", "did:plc:whelk"]); 1631 assert_eq!( 1632 subjects(&desc), 1633 vec!["did:plc:limpet", "did:plc:whelk"], 1634 "equal-createdAt entries keep an ascending subject tie-break regardless of sort direction" 1635 ); 1636} 1637 1638#[tokio::test] 1639async fn repo_error_outranks_paging_in_any_param_order() { 1640 let world = World::new(); 1641 let orders: &[&str] = &[ 1642 "/xrpc/sh.tangled.repo.listCollaborators?subject=notadid&limit=abc", 1643 "/xrpc/sh.tangled.repo.listCollaborators?limit=abc&subject=notadid", 1644 ]; 1645 let w = &world; 1646 stream::iter(orders.iter().copied()) 1647 .for_each(|query| async move { 1648 let (_, error) = get_error(w, query).await; 1649 assert_eq!( 1650 error, "InvalidRepo", 1651 "the repo extractor runs before paging by signature position for {query}" 1652 ); 1653 }) 1654 .await; 1655} 1656 1657#[tokio::test] 1658async fn service_metadata_endpoints_answer() { 1659 let world = World::new(); 1660 let wire = get_json(&world, "/xrpc/sh.tangled.knot.version").await; 1661 assert_eq!(wire["version"], "v1.15.0"); 1662 assert_eq!(wire["capabilities"], serde_json::json!(["knot-acl"])); 1663 1664 let owner = get_json(&world, "/xrpc/sh.tangled.owner").await; 1665 assert_eq!(owner["owner"], OWNER); 1666} 1667 1668fn publish_update(world: &World, repo: &str) -> EventCursor { 1669 world.state.events.publish(&GitRefUpdate::new( 1670 RepoDid::new(repo).unwrap(), 1671 Some(OwnerDid::new(OWNER).unwrap()), 1672 AccountDid::new("did:plc:nel").unwrap(), 1673 )) 1674} 1675 1676async fn serve_events(world: &World) -> std::net::SocketAddr { 1677 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); 1678 let addr = listener.local_addr().unwrap(); 1679 let router = world.router.clone(); 1680 tokio::spawn(async move { 1681 axum::serve( 1682 listener, 1683 router.into_make_service_with_connect_info::<std::net::SocketAddr>(), 1684 ) 1685 .await 1686 .unwrap(); 1687 }); 1688 addr 1689} 1690 1691type Ws = 1692 tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>; 1693 1694fn next_event(ws: &mut Ws) -> Pin<Box<dyn Future<Output = serde_json::Value> + '_>> { 1695 Box::pin(async move { 1696 let received = tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()) 1697 .await 1698 .expect("an event arrives within the timeout") 1699 .expect("the stream stays open") 1700 .expect("the frame is readable"); 1701 match received { 1702 tungstenite::Message::Text(text) => serde_json::from_str(text.as_str()).unwrap(), 1703 _ => next_event(ws).await, 1704 } 1705 }) 1706} 1707 1708#[tokio::test] 1709async fn the_events_stream_replays_resumes_and_rejects_bad_cursors() { 1710 let world = World::new(); 1711 let first = publish_update(&world, "did:plc:squid"); 1712 publish_update(&world, "did:plc:anemone"); 1713 let addr = serve_events(&world).await; 1714 1715 let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/events")) 1716 .await 1717 .unwrap(); 1718 let replayed_first = next_event(&mut ws).await; 1719 let replayed_second = next_event(&mut ws).await; 1720 assert_eq!(replayed_first["nsid"], "sh.tangled.git.refUpdate"); 1721 assert_eq!(replayed_first["event"]["repo"], "did:plc:squid"); 1722 assert_eq!(replayed_first["event"]["ownerDid"], OWNER); 1723 assert_eq!(replayed_first["event"]["committerDid"], "did:plc:nel"); 1724 assert_eq!(replayed_first["rkey"].as_str().unwrap().len(), 13); 1725 assert_eq!(replayed_second["event"]["repo"], "did:plc:anemone"); 1726 assert!( 1727 replayed_first["created"].as_i64().unwrap() < replayed_second["created"].as_i64().unwrap() 1728 ); 1729 1730 publish_update(&world, "did:plc:whelk"); 1731 let live = next_event(&mut ws).await; 1732 assert_eq!(live["event"]["repo"], "did:plc:whelk"); 1733 1734 let (mut resumed, _) = 1735 tokio_tungstenite::connect_async(format!("ws://{addr}/events?cursor={}", first.get())) 1736 .await 1737 .unwrap(); 1738 let resumed_event = next_event(&mut resumed).await; 1739 assert_eq!( 1740 resumed_event["event"]["repo"], "did:plc:anemone", 1741 "a cursor resumes past the event it names" 1742 ); 1743 1744 let (mut garbled, _) = 1745 tokio_tungstenite::connect_async(format!("ws://{addr}/events?cursor=banana")) 1746 .await 1747 .unwrap(); 1748 let replayed = next_event(&mut garbled).await; 1749 assert_eq!( 1750 replayed["event"]["repo"], "did:plc:squid", 1751 "a garbled cursor replays from the start" 1752 ); 1753} 1754 1755fn refused<T>(result: Result<T, tungstenite::Error>) { 1756 match result { 1757 Err(tungstenite::Error::Http(response)) => { 1758 assert_eq!(response.status().as_u16(), 503); 1759 } 1760 Err(other) => panic!("expected an http refusal: {other}"), 1761 Ok(_) => panic!("a subscriber past the limit connected"), 1762 } 1763} 1764 1765#[tokio::test] 1766async fn a_subscriber_beyond_the_events_limit_is_refused() { 1767 let world = World::new(); 1768 let addr = serve_events(&world).await; 1769 let saturated: Vec<_> = (0..16u8) 1770 .map(|octet| { 1771 world 1772 .state 1773 .subscriber_gate 1774 .try_admit(std::net::IpAddr::V4(std::net::Ipv4Addr::new( 1775 10, 0, 0, octet, 1776 ))) 1777 .expect("distinct peers fill the global limit") 1778 }) 1779 .collect(); 1780 refused(tokio_tungstenite::connect_async(format!("ws://{addr}/events")).await); 1781 drop(saturated); 1782} 1783 1784#[tokio::test] 1785async fn a_single_peer_cannot_monopolize_the_events_stream() { 1786 let world = World::new(); 1787 let addr = serve_events(&world).await; 1788 let held: Vec<_> = stream::iter(0..4) 1789 .then(|_| async { 1790 tokio_tungstenite::connect_async(format!("ws://{addr}/events")) 1791 .await 1792 .expect("a connection within the per-peer limit is admitted") 1793 .0 1794 }) 1795 .collect() 1796 .await; 1797 refused(tokio_tungstenite::connect_async(format!("ws://{addr}/events")).await); 1798 drop(held); 1799} 1800 1801#[tokio::test] 1802async fn set_default_branch_resolves_an_at_uri_repo_and_an_existing_branch() { 1803 let world = World::new(); 1804 let (did, work) = seeded(&world, "coral"); 1805 let bare = world.layout.repo_path(&did).unwrap(); 1806 sh_git(work.path(), &["branch", "release", "main"]); 1807 sh_git( 1808 work.path(), 1809 &["push", "-q", bare.to_str().unwrap(), "refs/heads/release"], 1810 ); 1811 1812 let (status, _) = post_authed( 1813 &world, 1814 "/xrpc/sh.tangled.repo.setDefaultBranch", 1815 OWNER, 1816 serde_json::json!({ 1817 "repo": format!("at://{OWNER}/sh.tangled.repo/coral"), 1818 "defaultBranch": "release", 1819 }), 1820 ) 1821 .await; 1822 assert_eq!(status, StatusCode::OK); 1823 1824 let repo = world.layout.open(&did).unwrap(); 1825 assert_eq!( 1826 repo.default_branch().unwrap().as_str(), 1827 "refs/heads/release", 1828 "the default head moved to the requested branch" 1829 ); 1830} 1831 1832#[tokio::test] 1833async fn delete_branch_removes_a_non_default_branch_then_reports_it_gone() { 1834 let world = World::new(); 1835 let (did, work) = seeded(&world, "kelp"); 1836 let bare = world.layout.repo_path(&did).unwrap(); 1837 sh_git(work.path(), &["branch", "feature", "main"]); 1838 sh_git( 1839 work.path(), 1840 &["push", "-q", bare.to_str().unwrap(), "refs/heads/feature"], 1841 ); 1842 1843 let at = format!("at://{OWNER}/sh.tangled.repo/kelp"); 1844 let (status, _) = post_authed( 1845 &world, 1846 "/xrpc/sh.tangled.repo.deleteBranch", 1847 OWNER, 1848 serde_json::json!({ "repo": at, "branch": "feature" }), 1849 ) 1850 .await; 1851 assert_eq!(status, StatusCode::OK); 1852 1853 let (status, body) = post_authed( 1854 &world, 1855 "/xrpc/sh.tangled.repo.deleteBranch", 1856 OWNER, 1857 serde_json::json!({ "repo": at, "branch": "feature" }), 1858 ) 1859 .await; 1860 assert_eq!(status, StatusCode::NOT_FOUND, "second delete: {body}"); 1861} 1862 1863#[tokio::test] 1864async fn bad_post_bodies_are_invalid_request() { 1865 let world = World::new(); 1866 let (_kelp, _wk) = seeded(&world, "kelp"); 1867 let (_barnacle, _wb) = seeded(&world, "barnacle"); 1868 1869 let cases: &[(&str, serde_json::Value)] = &[ 1870 ( 1871 "/xrpc/sh.tangled.repo.setDefaultBranch", 1872 serde_json::json!({ "repo": "not-an-at-uri", "defaultBranch": "main" }), 1873 ), 1874 ( 1875 "/xrpc/sh.tangled.repo.deleteBranch", 1876 serde_json::json!({ 1877 "repo": format!("at://{OWNER}/sh.tangled.repo/kelp"), 1878 "branch": "bad branch", 1879 }), 1880 ), 1881 ( 1882 "/xrpc/sh.tangled.repo.forkSync", 1883 serde_json::json!({ "did": OWNER, "name": "barnacle", "branch": "bad branch" }), 1884 ), 1885 ( 1886 "/xrpc/sh.tangled.repo.hiddenRef", 1887 serde_json::json!({ "repo": "nope", "forkRef": "feature", "remoteRef": "main" }), 1888 ), 1889 ]; 1890 let w = &world; 1891 stream::iter(cases) 1892 .for_each(|(path, value)| async move { 1893 assert_post_rejected(w, path, OWNER, value.clone()).await; 1894 }) 1895 .await; 1896} 1897 1898#[tokio::test] 1899async fn merge_applies_a_plain_patch_under_the_supplied_author() { 1900 let world = World::new(); 1901 let (_did, main_sha, feature_sha) = seeded_feature_branch(&world, "mussel"); 1902 let registered = RepoDid::new("did:plc:musselfixture").unwrap(); 1903 1904 let compared = get_json( 1905 &world, 1906 &format!( 1907 "/xrpc/sh.tangled.repo.compare?repo={registered}&rev1={main_sha}&rev2={feature_sha}" 1908 ), 1909 ) 1910 .await; 1911 let patch = compared["combined_patch_raw"].as_str().unwrap().to_string(); 1912 1913 let (status, body) = post_authed( 1914 &world, 1915 "/xrpc/sh.tangled.repo.merge", 1916 OWNER, 1917 serde_json::json!({ 1918 "did": OWNER, 1919 "name": "mussel", 1920 "branch": "main", 1921 "patch": patch, 1922 "authorName": "Teq", 1923 "authorEmail": "teq@nel.pet", 1924 "commitMessage": "merged kelp", 1925 }), 1926 ) 1927 .await; 1928 assert_eq!(status, StatusCode::OK, "merge failed: {body}"); 1929 1930 let log = get_json( 1931 &world, 1932 &format!("/xrpc/sh.tangled.repo.log?repo={registered}&ref=main"), 1933 ) 1934 .await; 1935 let top = &log["commits"][0]; 1936 assert_eq!( 1937 top["author"]["Name"], "Teq", 1938 "the supplied author rode through" 1939 ); 1940 assert!( 1941 top["message"].as_str().unwrap().contains("merged kelp"), 1942 "the supplied commit message rode through: {}", 1943 top["message"] 1944 ); 1945} 1946 1947#[tokio::test] 1948async fn create_mints_a_did_plc_repo_with_the_requested_default_branch() { 1949 let world = World::new(); 1950 world.add_member(OWNER, OWNER, 1_000); 1951 let (status, body) = post_authed( 1952 &world, 1953 "/xrpc/sh.tangled.repo.create", 1954 OWNER, 1955 serde_json::json!({ "rkey": "squidkey", "name": "squid", "defaultBranch": "trunk" }), 1956 ) 1957 .await; 1958 assert_eq!(status, StatusCode::OK, "create failed: {body}"); 1959 let repo_did = body["repoDid"].as_str().unwrap(); 1960 assert!( 1961 repo_did.starts_with("did:plc:"), 1962 "minted a did:plc: {repo_did}" 1963 ); 1964 let did = RepoDid::new(repo_did).unwrap(); 1965 let repo = world.layout.open(&did).unwrap(); 1966 assert_eq!( 1967 repo.default_branch().unwrap().as_str(), 1968 "refs/heads/trunk", 1969 "the requested default branch became HEAD" 1970 ); 1971}