This repository has no description
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}