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 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}