git-ents.gitmain
⌘K
foforge
router.rs3531 lines · 119.6 KB · rusthistorycomment on this file
1//! Integration coverage for `docs/spec/roots.adoc`'s web-frontend
2//! requirements, driven entirely through [`tower::ServiceExt::oneshot`]
3//! against [`ents_web::router`] -- no socket is ever bound anywhere in
4//! this file, which is itself part of the proof for `roots.web-agnostic`:
5//! every one of these requests is exercised the same way an in-process
6//! webview embedding would drive them.
7#![allow(clippy::expect_used, reason = "integration test")]
8#![allow(clippy::unwrap_used, reason = "integration test")]
9
10use std::sync::Arc;
11
12use axum::body::Body;
13use axum::http::{Request, StatusCode, header};
14use ents_kiln::Toolchain;
15use ents_model::{Account, Effect, MemberId, Provenance, Redaction, ResultRecord, Status};
16use ents_receive::{Mode, NullEventSink};
17use ents_testutil::{
18 CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, record_result, write_commit,
19 write_meta_entity,
20};
21use ents_web::identity::SigningIdentity;
22use ents_web::state::AppState;
23use gix::bstr::ByteSlice as _;
24use gix_object::tree::{Entry, EntryKind};
25use gix_object::{Kind, Tree, Write as _};
26use http_body_util::BodyExt as _;
27use tower::ServiceExt as _;
28
29/// A fixture [`SigningIdentity`] wrapping a deterministic test key, named
30/// so a test can tell two different injected identities apart by their
31/// commit author name alone.
32struct FixtureIdentity {
33 name: &'static str,
34 key: Keypair,
35}
36
37impl SigningIdentity for FixtureIdentity {
38 fn actor(&self) -> gix::actor::Signature {
39 gix::actor::Signature {
40 name: self.name.into(),
41 email: format!("{}@ents.test", self.name).into(),
42 time: gix::date::Time {
43 seconds: 1_000,
44 offset: 0,
45 },
46 }
47 }
48
49 fn sign(&self, payload: &[u8]) -> String {
50 self.key.sign(payload)
51 }
52
53 fn public_openssh(&self) -> String {
54 self.key.public_openssh()
55 }
56}
57
58fn build_state(identity: FixtureIdentity) -> Arc<AppState<ObjectStore>> {
59 Arc::new(AppState::new(
60 Box::new(MemRefStore::default()),
61 ObjectStore::default(),
62 Box::new(NullEventSink),
63 Mode::Advisory,
64 Box::new(identity),
65 std::env::temp_dir(),
66 ))
67}
68
69/// Like [`build_state`], but `path` names a real, on-disk repository
70/// rather than the shared system temp directory -- `crate::pages::files`
71/// opens `state.path` directly with `gix::open`, so its tests need an
72/// actual `HEAD` to browse, not just the in-memory ref/object store every
73/// other test in this file exercises.
74fn build_state_at(
75 identity: FixtureIdentity,
76 path: std::path::PathBuf,
77) -> Arc<AppState<ObjectStore>> {
78 Arc::new(AppState::new(
79 Box::new(MemRefStore::default()),
80 ObjectStore::default(),
81 Box::new(NullEventSink),
82 Mode::Advisory,
83 Box::new(identity),
84 path,
85 ))
86}
87
88/// Like [`build_state`], but `refs`/`objects` are already populated --
89/// what the toolchain-marker tests below use to seed a ref store directly
90/// with plain `gix_object` writes (a wrong-shape tree no `ents-kiln`
91/// helper would ever produce), rather than through a signed write path.
92fn build_state_with(
93 identity: FixtureIdentity,
94 refs: MemRefStore,
95 objects: ObjectStore,
96) -> Arc<AppState<ObjectStore>> {
97 Arc::new(AppState::new(
98 Box::new(refs),
99 objects,
100 Box::new(NullEventSink),
101 Mode::Advisory,
102 Box::new(identity),
103 std::env::temp_dir(),
104 ))
105}
106
107/// Land a `refs/meta/toolchains/<name>` ref pointing at a tree shaped like
108/// the pre-redo `git_toolchain::Bin` schema this repository's own
109/// `refs/meta/toolchains/{rust,sccache,zig}` still carry: a `recipe` entry
110/// that is itself a tree, not the blob today's `ents_kiln::Toolchain::recipe:
111/// String` expects -- `facet_git_tree::deserialize` reads `recipe` as a
112/// scalar (a blob) and fails with `NotABlob` on exactly this shape, the
113/// same failure `git ents serve` hits reading this repository's own real
114/// legacy toolchain refs (piece 1's bug report). Built from plain
115/// `gix_object` writes, not `ents_kiln::toolchain::import` (which only ever
116/// writes today's shape) or `write_meta_entity` (which only ever writes a
117/// value that already round-trips through `facet_git_tree`).
118fn write_legacy_toolchain(refs: &MemRefStore, objects: &ObjectStore, name: &str) {
119 let name_blob = objects
120 .write_buf(Kind::Blob, name.as_bytes())
121 .expect("write");
122 let recipe_tree = objects
123 .write(&Tree {
124 entries: Vec::new(),
125 })
126 .expect("write");
127 let mut entries = vec![
128 Entry {
129 mode: EntryKind::Blob.into(),
130 filename: "name".into(),
131 oid: name_blob,
132 },
133 Entry {
134 mode: EntryKind::Tree.into(),
135 filename: "recipe".into(),
136 oid: recipe_tree,
137 },
138 ];
139 entries.sort();
140 let tree = objects.write(&Tree { entries }).expect("write");
141 let tip = write_commit(
142 objects,
143 &CommitSpec {
144 tree,
145 parents: Vec::new(),
146 message: format!("legacy toolchain {name}"),
147 seconds: 100,
148 },
149 None,
150 );
151 let refname: gix::refs::FullName = format!("refs/meta/toolchains/{name}")
152 .try_into()
153 .expect("valid refname");
154 refs.set(refname.as_ref(), tip);
155}
156
157/// Initialize a real git repository at a fresh tempdir, seed it with
158/// `files` (path, contents), and commit them on `HEAD` -- what
159/// `crate::pages::files`'s tests below browse.
160fn seed_repo(files: &[(&str, &str)]) -> tempfile::TempDir {
161 let dir = tempfile::tempdir().expect("tempdir");
162 let git = |args: &[&str]| {
163 let status = std::process::Command::new("git")
164 .arg("-C")
165 .arg(dir.path())
166 .args(args)
167 .status()
168 .expect("git runs");
169 assert!(status.success(), "git {args:?} failed");
170 };
171 git(&["init", "-q"]);
172 for (name, contents) in files {
173 let path = dir.path().join(name);
174 if let Some(parent) = path.parent() {
175 std::fs::create_dir_all(parent).expect("mkdir -p");
176 }
177 std::fs::write(&path, contents).expect("write fixture file");
178 }
179 git(&["add", "-A"]);
180 git(&[
181 "-c",
182 "user.name=t",
183 "-c",
184 "user.email=t@example.com",
185 "commit",
186 "-q",
187 "-m",
188 "seed",
189 ]);
190 dir
191}
192
193/// The full hex object id of `dir`'s current `HEAD` commit, read via `git
194/// rev-parse` -- what `crate::pages::commits`'s tests below build
195/// `/commit/{oid}` request paths from.
196fn head_oid(dir: &std::path::Path) -> String {
197 let output = std::process::Command::new("git")
198 .arg("-C")
199 .arg(dir)
200 .args(["rev-parse", "HEAD"])
201 .output()
202 .expect("git runs");
203 assert!(output.status.success(), "git rev-parse HEAD failed");
204 String::from_utf8(output.stdout)
205 .expect("utf8 oid")
206 .trim()
207 .to_owned()
208}
209
210/// Commit a further change to a file already tracked in `dir` -- what the
211/// comment tests below use to move `HEAD` past a comment's own anchor
212/// commit, so its projection has something to react to.
213fn commit_change(dir: &std::path::Path, path: &str, contents: &str, message: &str) {
214 std::fs::write(dir.join(path), contents).expect("write fixture file");
215 let git = |args: &[&str]| {
216 let status = std::process::Command::new("git")
217 .arg("-C")
218 .arg(dir)
219 .args(args)
220 .status()
221 .expect("git runs");
222 assert!(status.success(), "git {args:?} failed");
223 };
224 git(&["add", "-A"]);
225 git(&[
226 "-c",
227 "user.name=t",
228 "-c",
229 "user.email=t@example.com",
230 "commit",
231 "-q",
232 "-m",
233 message,
234 ]);
235}
236
237/// `GET path` and return its body decoded as UTF-8, asserting a 200 --
238/// the read-back half of the many "mutate, then observe" tests below, so
239/// each does not re-spell the collect/decode dance inline.
240async fn get_body(router: &axum::Router, path: &str) -> String {
241 let response = router
242 .clone()
243 .oneshot(Request::get(path).body(Body::empty()).expect("request"))
244 .await
245 .expect("in-process call");
246 assert_eq!(response.status(), StatusCode::OK, "GET {path}");
247 let bytes = response
248 .into_body()
249 .collect()
250 .await
251 .expect("body")
252 .to_bytes();
253 String::from_utf8(bytes.to_vec()).expect("utf8 html")
254}
255
256/// Establish a session against `router` via a `GET` to `path`, returning
257/// its cookie header and CSRF token -- the same extraction
258/// `csrf_is_required_and_checked_on_every_state_changing_request` performs
259/// inline, factored out here since every comment test below needs one.
260async fn session_cookie_and_csrf(
261 router: &axum::Router,
262 state: &AppState<ObjectStore>,
263 path: &str,
264) -> (String, String) {
265 let response = router
266 .clone()
267 .oneshot(Request::get(path).body(Body::empty()).expect("request"))
268 .await
269 .expect("in-process call");
270 let cookie = response
271 .headers()
272 .get(header::SET_COOKIE)
273 .expect("a fresh GET always mints a session cookie")
274 .to_str()
275 .expect("ascii")
276 .to_owned();
277 let session_id = cookie
278 .split(';')
279 .next()
280 .expect("at least one segment")
281 .split_once('=')
282 .expect("name=value")
283 .1
284 .to_owned();
285 let csrf = state
286 .sessions
287 .get(&session_id)
288 .expect("the session this cookie names is held in this server's own memory")
289 .csrf;
290 (cookie, csrf)
291}
292
293/// `POST /comments`, anchoring `body` to `path` (`lines`, `<start>:<end>`)
294/// at `rev` -- what the comment tests below seed a real comment through,
295/// exercising the actual signed-write path (`ents_forge::comment::add`)
296/// rather than poking the ref store directly. Asserts the write succeeded
297/// (a redirect to the new comment's own page) and returns the new comment's
298/// id, read from that redirect's `Location` (`/comments/<id>`) -- what the
299/// thread-action tests below drive reply/resolve/reopen against.
300async fn seed_comment(
301 router: &axum::Router,
302 state: &AppState<ObjectStore>,
303 path: &str,
304 body: &str,
305 lines: &str,
306 rev: &str,
307) -> String {
308 let (cookie, csrf) = session_cookie_and_csrf(router, state, "/comments").await;
309 let form = format!(
310 "path={path}&body={}&lines={lines}&rev={rev}&csrf={csrf}",
311 body.replace(' ', "+")
312 );
313 let response = router
314 .clone()
315 .oneshot(
316 Request::post("/comments")
317 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
318 .header(header::COOKIE, cookie)
319 .body(Body::from(form))
320 .expect("request"),
321 )
322 .await
323 .expect("in-process call");
324 assert!(
325 response.status().is_redirection(),
326 "comment write did not succeed: {:?}",
327 response.status()
328 );
329 response
330 .headers()
331 .get(header::LOCATION)
332 .expect("a successful comment write redirects to the new comment")
333 .to_str()
334 .expect("ascii")
335 .strip_prefix("/comments/")
336 .expect("redirect targets /comments/<id>")
337 .to_owned()
338}
339
340/// `roots.local`: this crate's route table never exposes git's own
341/// smart-HTTP transport -- a request that would name it (`info/refs` with
342/// a `service` query, exactly the URL stock `git clone`/`git fetch` sends
343/// a dumb or smart HTTP backend) falls through to axum's ordinary 404,
344/// not a git wire-protocol response.
345#[tokio::test]
346// @relation(roots.local, scope=function, role=Verifies)
347async fn smart_http_transport_is_never_exposed() {
348 let state = build_state(FixtureIdentity {
349 name: "local-user",
350 key: Keypair::from_seed(1),
351 });
352 let router = ents_web::router(state);
353
354 let response = router
355 .oneshot(
356 Request::get("/info/refs?service=git-upload-pack")
357 .body(Body::empty())
358 .expect("request"),
359 )
360 .await
361 .expect("in-process call");
362 assert_eq!(response.status(), StatusCode::NOT_FOUND);
363}
364
365/// `GET /style.css` is reachable with no session at all (every other route
366/// runs behind the session middleware, but that middleware only ever
367/// attaches a session -- it never gates), and serves this crate's own
368/// hand-rolled stylesheet.
369#[tokio::test]
370async fn style_css_is_served_with_no_session_required() {
371 let state = build_state(FixtureIdentity {
372 name: "local-user",
373 key: Keypair::from_seed(1),
374 });
375 let router = ents_web::router(state);
376
377 let response = router
378 .oneshot(
379 Request::get("/style.css")
380 .body(Body::empty())
381 .expect("request"),
382 )
383 .await
384 .expect("in-process call");
385 assert_eq!(response.status(), StatusCode::OK);
386 assert_eq!(
387 response
388 .headers()
389 .get(header::CONTENT_TYPE)
390 .expect("content-type")
391 .to_str()
392 .expect("ascii"),
393 "text/css; charset=utf-8"
394 );
395 let body = response
396 .into_body()
397 .collect()
398 .await
399 .expect("body")
400 .to_bytes();
401 let body = String::from_utf8(body.to_vec()).expect("utf8 css");
402 assert!(!body.is_empty());
403 assert!(body.contains("--bg"));
404}
405
406/// `roots.web-agnostic`: the dashboard actually renders real content
407/// in-process, with no socket bound anywhere in this test -- reading the
408/// body back (rather than only checking the status) is what makes this
409/// test more than a routing smoke check.
410#[tokio::test]
411// @relation(roots.web-agnostic, scope=function, role=Verifies)
412async fn dashboard_renders_in_process_with_no_socket_bound() {
413 let state = build_state(FixtureIdentity {
414 name: "local-user",
415 key: Keypair::from_seed(1),
416 });
417 let router = ents_web::router(state);
418
419 let response = router
420 .oneshot(Request::get("/").body(Body::empty()).expect("request"))
421 .await
422 .expect("in-process call");
423 assert_eq!(response.status(), StatusCode::OK);
424 let body = response
425 .into_body()
426 .collect()
427 .await
428 .expect("body")
429 .to_bytes();
430 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
431 assert!(body.contains("Working tree"));
432 assert!(body.contains("Issues"));
433 // The shell chrome renders on every page: the icon rail, the sticky
434 // top bar, and the bar's palette search form.
435 assert!(body.contains("class=\"rail\""));
436 assert!(body.contains("class=\"wb-bar\""));
437 assert!(body.contains("class=\"palette\""));
438 assert!(body.contains("Jump to file, commit, issue, member"));
439}
440
441/// `roots.web-agnostic`: the shell's `.wb-bar` top bar names the served
442/// repository (its directory name) and, when `HEAD` resolves to a branch,
443/// renders that branch in the `.branch` pill -- both read once off
444/// `AppState.path`, so every page's chrome reflects the actual repository
445/// being served rather than a placeholder.
446#[tokio::test]
447async fn the_top_bar_names_the_served_repo_and_its_head_branch() {
448 let dir = seed_repo(&[("README.md", "# hi\n")]);
449 // `git init` picks the default branch name (which varies by host git
450 // config); rename it so the pill's text is deterministic to assert.
451 let status = std::process::Command::new("git")
452 .arg("-C")
453 .arg(dir.path())
454 .args(["branch", "-m", "trunk"])
455 .status()
456 .expect("git runs");
457 assert!(status.success(), "git branch -m failed");
458 let repo_name = dir
459 .path()
460 .file_name()
461 .expect("tempdir has a name")
462 .to_string_lossy()
463 .into_owned();
464
465 let state = build_state_at(
466 FixtureIdentity {
467 name: "local-user",
468 key: Keypair::from_seed(1),
469 },
470 dir.path().to_owned(),
471 );
472 let router = ents_web::router(state);
473
474 let response = router
475 .oneshot(Request::get("/").body(Body::empty()).expect("request"))
476 .await
477 .expect("in-process call");
478 assert_eq!(response.status(), StatusCode::OK);
479 let body = response
480 .into_body()
481 .collect()
482 .await
483 .expect("body")
484 .to_bytes();
485 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
486 assert!(body.contains("class=\"wb-bar\""));
487 assert!(
488 body.contains(&repo_name),
489 "the served repo's directory name {repo_name:?} must appear in the top bar"
490 );
491 assert!(
492 body.contains("class=\"branch\""),
493 "a resolvable HEAD must render the branch pill"
494 );
495 assert!(
496 body.contains("trunk"),
497 "the pill carries the short branch name"
498 );
499}
500
501/// `roots.web-agnostic`: the workbench dashboard (`GET /`) renders its
502/// four sections -- Working tree, Needs attention, Issues, History --
503/// against a real repository, with real content in each: the dirty file
504/// shows up as a working-tree row, the seeded open comment as a
505/// needs-attention row (naming its anchored path), the seeded open issue
506/// as an issue, and the `HEAD` commit in the History card with its
507/// Scoped-Commits scope chip.
508#[tokio::test]
509async fn dashboard_renders_the_four_sections_with_real_content() {
510 let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
511 // Commit a scoped subject so the History card has a chip to parse.
512 commit_change(
513 dir.path(),
514 "src/main.rs",
515 "line 1\nline 2\nline 3\nline 4\n",
516 "model: grow main by a line",
517 );
518 let oid = head_oid(dir.path());
519 // Dirty the working tree after the commit, for the Working tree lane.
520 std::fs::write(dir.path().join("src/main.rs"), "changed\n").expect("dirty the tree");
521 let state = build_state_at(
522 FixtureIdentity {
523 name: "local-user",
524 key: Keypair::from_seed(1),
525 },
526 dir.path().to_owned(),
527 );
528 let router = ents_web::router(state.clone());
529 let comment_id =
530 seed_comment(&router, &state, "src/main.rs", "worth a look", "2:2", &oid).await;
531 seed_issue(&router, &state, "Ship the desk", "open", "", "").await;
532
533 let body = get_body(&router, "/").await;
534 for header in ["Working tree", "Needs attention", "Issues", "History"] {
535 assert!(body.contains(header), "the {header} section renders");
536 }
537 assert!(
538 body.contains("href=\"/files/src/main.rs\"") && body.contains("modified"),
539 "the dirty file lists as a working-tree change"
540 );
541 assert!(
542 body.contains(&format!("/comments/{comment_id}")) && body.contains("src/main.rs:2"),
543 "the open comment links out and names its anchored path"
544 );
545 assert!(
546 body.contains("Ship the desk"),
547 "the open issue lists on the Issues card"
548 );
549 assert!(
550 body.contains(&format!("/commit/{oid}")),
551 "the History card links the HEAD commit"
552 );
553 assert!(
554 body.contains("class=\"scope scope-c") && body.contains(">model</span>"),
555 "the scoped subject chips its scope"
556 );
557}
558
559/// `GET /` on an unborn `HEAD` (a freshly initialized, still-empty
560/// repository) still renders all four sections, each degrading to its own
561/// empty-state row rather than a placeholder commit or a 500.
562#[tokio::test]
563async fn dashboard_degrades_every_section_on_an_unborn_head() {
564 let dir = tempfile::tempdir().expect("tempdir");
565 let status = std::process::Command::new("git")
566 .arg("-C")
567 .arg(dir.path())
568 .args(["init", "-q"])
569 .status()
570 .expect("git runs");
571 assert!(status.success(), "git init failed");
572 let state = build_state_at(
573 FixtureIdentity {
574 name: "local-user",
575 key: Keypair::from_seed(1),
576 },
577 dir.path().to_owned(),
578 );
579 let router = ents_web::router(state);
580
581 let body = get_body(&router, "/").await;
582 for header in ["Working tree", "Needs attention", "Issues", "History"] {
583 assert!(body.contains(header), "the {header} section renders");
584 }
585 assert!(!body.contains("/commit/"), "no placeholder commit links");
586 assert!(body.contains("No commits yet."));
587}
588
589/// `GET /account` states who the session is (`roots.web-signing`): with
590/// the serving identity's key enrolled, the page renders that member's
591/// own identity card (never a login or signup form -- the signing key is
592/// the identity); with no matching member, it shows the unenrolled key
593/// itself.
594#[tokio::test]
595// @relation(roots.web-signing, scope=function, role=Verifies)
596async fn account_page_names_the_signed_in_member() {
597 let key = Keypair::from_seed(1);
598 let refs = MemRefStore::default();
599 let objects = ObjectStore::default();
600 enroll_member(
601 &refs,
602 &objects,
603 "joey",
604 &key,
605 Provenance::AdminRegistered,
606 100,
607 );
608 let state = build_state_with(
609 FixtureIdentity {
610 name: "local-user",
611 key: Keypair::from_seed(1),
612 },
613 refs,
614 objects,
615 );
616 let body = get_body(&ents_web::router(state), "/account").await;
617 assert!(
618 body.contains("Signed in as the member below"),
619 "the page states the session's identity"
620 );
621 assert!(
622 body.contains(">joey</a>"),
623 "the enrolled member's card renders"
624 );
625
626 let stranger = build_state(FixtureIdentity {
627 name: "stranger",
628 key: Keypair::from_seed(2),
629 });
630 let body = get_body(&ents_web::router(stranger), "/account").await;
631 assert!(
632 body.contains("not enrolled as a"),
633 "an unenrolled key is stated, not hidden behind a signup form"
634 );
635}
636
637/// `roots.web-session`: a state-changing request with no CSRF token at
638/// all is a bad request (axum's own `Form` rejection); one with the wrong
639/// token is refused by this crate's own check; the session cookie a `GET`
640/// mints is required to learn the right one at all.
641#[tokio::test]
642// @relation(roots.web-session, scope=function, role=Verifies)
643async fn csrf_is_required_and_checked_on_every_state_changing_request() {
644 let state = build_state(FixtureIdentity {
645 name: "local-user",
646 key: Keypair::from_seed(1),
647 });
648 let router = ents_web::router(Arc::clone(&state));
649
650 // No CSRF field at all in the POST body.
651 let response = router
652 .clone()
653 .oneshot(
654 Request::post("/account")
655 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
656 .body(Body::from("member=jdc&login=jdc@ents.test"))
657 .expect("request"),
658 )
659 .await
660 .expect("in-process call");
661 assert!(
662 !response.status().is_success(),
663 "a POST with no csrf field at all must not succeed"
664 );
665
666 // A GET establishes a session; extract its id from Set-Cookie, then
667 // read the matching CSRF token directly out of the (in-memory-only)
668 // session store this test built.
669 let get_response = router
670 .clone()
671 .oneshot(
672 Request::get("/account")
673 .body(Body::empty())
674 .expect("request"),
675 )
676 .await
677 .expect("in-process call");
678 let cookie = get_response
679 .headers()
680 .get(header::SET_COOKIE)
681 .expect("a fresh GET always mints a session cookie")
682 .to_str()
683 .expect("ascii")
684 .to_owned();
685 let session_id = cookie
686 .split(';')
687 .next()
688 .expect("at least one segment")
689 .split_once('=')
690 .expect("name=value")
691 .1
692 .to_owned();
693 let csrf = state
694 .sessions
695 .get(&session_id)
696 .expect("the session this cookie names is held in this server's own memory")
697 .csrf;
698
699 // The wrong token is refused.
700 let response = router
701 .clone()
702 .oneshot(
703 Request::post("/account")
704 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
705 .header(header::COOKIE, cookie.clone())
706 .body(Body::from(
707 "member=jdc&login=jdc@ents.test&csrf=not-the-token",
708 ))
709 .expect("request"),
710 )
711 .await
712 .expect("in-process call");
713 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
714
715 // The right token, carried by the same session cookie, succeeds.
716 let response = router
717 .clone()
718 .oneshot(
719 Request::post("/account")
720 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
721 .header(header::COOKIE, cookie)
722 .body(Body::from(format!(
723 "member=jdc&login=jdc@ents.test&csrf={csrf}"
724 )))
725 .expect("request"),
726 )
727 .await
728 .expect("in-process call");
729 assert!(
730 response.status().is_redirection(),
731 "{:?}",
732 response.status()
733 );
734}
735
736/// `roots.web-session`: a session is recognized across requests that
737/// carry its cookie (no fresh `Set-Cookie` reissued), and is held only in
738/// this server's own process memory -- a second, independently built
739/// state/router pair (standing in for a second process) never recognizes
740/// a cookie the first one minted.
741#[tokio::test]
742// @relation(roots.web-session, scope=function, role=Verifies)
743async fn a_session_is_recognized_across_requests_but_never_across_servers() {
744 let state_a = build_state(FixtureIdentity {
745 name: "a",
746 key: Keypair::from_seed(1),
747 });
748 let router_a = ents_web::router(Arc::clone(&state_a));
749
750 let first = router_a
751 .clone()
752 .oneshot(Request::get("/").body(Body::empty()).expect("request"))
753 .await
754 .expect("in-process call");
755 let cookie = first
756 .headers()
757 .get(header::SET_COOKIE)
758 .expect("first request mints a session")
759 .clone();
760
761 let second = router_a
762 .clone()
763 .oneshot(
764 Request::get("/")
765 .header(header::COOKIE, cookie.clone())
766 .body(Body::empty())
767 .expect("request"),
768 )
769 .await
770 .expect("in-process call");
771 assert!(
772 second.headers().get(header::SET_COOKIE).is_none(),
773 "a recognized session must not be re-minted"
774 );
775
776 // A second server (fresh in-memory session store) never recognizes
777 // the first server's cookie.
778 let state_b = build_state(FixtureIdentity {
779 name: "b",
780 key: Keypair::from_seed(2),
781 });
782 let router_b = ents_web::router(state_b);
783 let third = router_b
784 .oneshot(
785 Request::get("/")
786 .header(header::COOKIE, cookie)
787 .body(Body::empty())
788 .expect("request"),
789 )
790 .await
791 .expect("in-process call");
792 assert!(
793 third.headers().get(header::SET_COOKIE).is_some(),
794 "a foreign session id must be treated as absent, minting a fresh one"
795 );
796}
797
798/// `roots.web-signing`, `roots.web-agnostic`: the identical page handler,
799/// reached through the identical route, signs each request's mutation
800/// commit with whichever [`SigningIdentity`] its own composition root
801/// injected -- never a fixed or shared one. This is the crate-level proof
802/// the development plan assigns this phase; wiring an actual hosted
803/// server-key identity behind `git-ents-server` is phase 8's job (see
804/// this crate's own top-level doc).
805#[tokio::test]
806// @relation(roots.web-signing, roots.web-agnostic, scope=function, role=Verifies)
807async fn each_request_is_signed_by_its_own_injected_identity_never_a_shared_one() {
808 for (name, seed) in [("local-style", 11u8), ("hosted-style", 22u8)] {
809 let state = build_state(FixtureIdentity {
810 name,
811 key: Keypair::from_seed(seed),
812 });
813 let router = ents_web::router(Arc::clone(&state));
814
815 let get_response = router
816 .clone()
817 .oneshot(
818 Request::get("/account")
819 .body(Body::empty())
820 .expect("request"),
821 )
822 .await
823 .expect("in-process call");
824 let cookie = get_response
825 .headers()
826 .get(header::SET_COOKIE)
827 .expect("session")
828 .to_str()
829 .expect("ascii")
830 .to_owned();
831 let session_id = cookie
832 .split(';')
833 .next()
834 .expect("segment")
835 .split_once('=')
836 .expect("name=value")
837 .1
838 .to_owned();
839 let csrf = state.sessions.get(&session_id).expect("session").csrf;
840
841 let response = router
842 .oneshot(
843 Request::post("/account")
844 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
845 .header(header::COOKIE, cookie)
846 .body(Body::from(format!(
847 "member=jdc&login=jdc@ents.test&csrf={csrf}"
848 )))
849 .expect("request"),
850 )
851 .await
852 .expect("in-process call");
853 assert!(response.status().is_redirection());
854
855 // Read the commit this request wrote back directly (this test's
856 // own retained `state` handle, not a second connection) and
857 // confirm its author is exactly this iteration's own identity.
858 let name_ref: gix::refs::FullName = ents_model::namespace::ACCOUNT_REF
859 .try_into()
860 .expect("valid");
861 let tip = state
862 .refs
863 .get(name_ref.as_ref())
864 .expect("readable")
865 .expect("account was just written");
866 let mut buf = Vec::new();
867 let objects = state.objects();
868 let data = gix_object::Find::try_find(&*objects, &tip, &mut buf)
869 .expect("read")
870 .expect("present");
871 let commit = gix_object::CommitRef::from_bytes(data.data, tip.kind()).expect("commit");
872 assert!(
873 commit.author.to_str_lossy().contains(name),
874 "commit author {:?} must carry this iteration's own identity name {name:?}",
875 commit.author.to_str_lossy()
876 );
877
878 let tree = commit.tree();
879 let account: Account = facet_git_tree::deserialize(&tree, &*objects).expect("typed tree");
880 assert_eq!(account.member, MemberId::new("jdc"));
881 }
882}
883
884/// `GET /files` lists the served repository's root directory: every
885/// top-level entry, directory or file, appears as a link.
886#[tokio::test]
887async fn files_root_lists_the_repository_root() {
888 let dir = seed_repo(&[
889 ("README.adoc", "= Welcome\n\nHello.\n"),
890 ("docs/x.md", "# Doc Title\n\nSome text.\n"),
891 ("src/main.rs", "fn main() {\n let ok = 1 < 2;\n}\n"),
892 ]);
893 let state = build_state_at(
894 FixtureIdentity {
895 name: "local-user",
896 key: Keypair::from_seed(1),
897 },
898 dir.path().to_owned(),
899 );
900 let router = ents_web::router(state);
901
902 let response = router
903 .oneshot(Request::get("/files").body(Body::empty()).expect("request"))
904 .await
905 .expect("in-process call");
906 assert_eq!(response.status(), StatusCode::OK);
907 let body = response
908 .into_body()
909 .collect()
910 .await
911 .expect("body")
912 .to_bytes();
913 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
914 assert!(body.contains("README.adoc"));
915 assert!(body.contains("docs"));
916 assert!(body.contains("src"));
917}
918
919/// `GET /files` renders the root `README` as a document card below the
920/// listing -- re-homed from the old overview dashboard, so the repository
921/// still introduces itself somewhere.
922#[tokio::test]
923async fn files_root_renders_the_readme_below_the_listing() {
924 let dir = seed_repo(&[
925 ("README.md", "# Welcome\n\nThe project overview.\n"),
926 ("src/main.rs", "fn main() {}\n"),
927 ]);
928 let state = build_state_at(
929 FixtureIdentity {
930 name: "local-user",
931 key: Keypair::from_seed(1),
932 },
933 dir.path().to_owned(),
934 );
935 let router = ents_web::router(state);
936
937 let body = get_body(&router, "/files").await;
938 assert!(
939 body.contains("<h1>Welcome</h1>"),
940 "the README renders as HTML, not raw markdown"
941 );
942 let listing = body.find("href=\"/files/src\"").expect("listing renders");
943 let readme = body.find("<h1>Welcome</h1>").expect("README renders");
944 assert!(listing < readme, "the README card sits below the listing");
945}
946
947/// The master-detail splits (`crate::pages::layout_split`): a blob view
948/// renders a `.tree` sidebar with its own entry active and its siblings
949/// listed; a commit page renders the compact history sidebar with the
950/// viewed commit active; the issues page renders its list beside the
951/// composer.
952#[tokio::test]
953async fn split_pages_render_a_sidebar_with_the_current_selection_active() {
954 let dir = seed_repo(&[
955 ("src/main.rs", "fn main() {}\n"),
956 ("src/lib.rs", "pub fn f() {}\n"),
957 ("README.md", "# hi\n"),
958 ]);
959 let oid = head_oid(dir.path());
960 let state = build_state_at(
961 FixtureIdentity {
962 name: "local-user",
963 key: Keypair::from_seed(1),
964 },
965 dir.path().to_owned(),
966 );
967 let router = ents_web::router(state.clone());
968 let issue_id = seed_issue(&router, &state, "Split the panes", "open", "", "").await;
969
970 let blob = get_body(&router, "/files/src/main.rs").await;
971 assert!(blob.contains("class=\"tree\""), "the blob page splits");
972 assert!(
973 blob.contains(">main.rs</a>") && blob.contains("active"),
974 "the viewed blob's own entry renders in the sidebar"
975 );
976 assert!(
977 blob.contains("href=\"/files/src/lib.rs\""),
978 "its sibling entries render beside it"
979 );
980 assert!(
981 blob.contains("class=\"pane\""),
982 "the content sits in a pane"
983 );
984
985 let commit = get_body(&router, &format!("/commit/{oid}")).await;
986 assert!(commit.contains("class=\"tree\""), "the commit page splits");
987 assert!(
988 commit.contains(&format!("class=\"active\" href=\"/commit/{oid}\"")),
989 "the viewed commit highlights in the history sidebar"
990 );
991
992 let issues = get_body(&router, "/issues").await;
993 assert!(issues.contains("class=\"tree\""), "the issues page splits");
994 assert!(
995 issues.contains("Split the panes") && issues.contains("Open an Issue"),
996 "the list and the composer render side by side"
997 );
998 let detail = get_body(&router, &format!("/issues/{issue_id}")).await;
999 assert!(
1000 detail.contains(&format!(
1001 "class=\"side-row active\" href=\"/issues/{issue_id}\""
1002 )),
1003 "the viewed issue highlights in the sidebar"
1004 );
1005}
1006
1007/// `GET /files/<path>` on a plain-text blob with no recognized grammar
1008/// renders a line-numbered, escaped `pre.blob-code` source view -- no
1009/// syntax highlighting, and no unescaped source.
1010#[tokio::test]
1011async fn files_blob_view_renders_a_plain_text_file() {
1012 let dir = seed_repo(&[("notes.txt", "true and 1 < 2\n")]);
1013 let state = build_state_at(
1014 FixtureIdentity {
1015 name: "local-user",
1016 key: Keypair::from_seed(1),
1017 },
1018 dir.path().to_owned(),
1019 );
1020 let router = ents_web::router(state);
1021
1022 let response = router
1023 .oneshot(
1024 Request::get("/files/notes.txt")
1025 .body(Body::empty())
1026 .expect("request"),
1027 )
1028 .await
1029 .expect("in-process call");
1030 assert_eq!(response.status(), StatusCode::OK);
1031 let body = response
1032 .into_body()
1033 .collect()
1034 .await
1035 .expect("body")
1036 .to_bytes();
1037 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1038 assert!(body.contains("blob-nums"));
1039 assert!(body.contains("<td class=\"blob-code\"><code>"));
1040 assert!(body.contains("1 &lt; 2"));
1041}
1042
1043/// `GET /files/<path>` on a `.rs` blob renders syntax-highlighted source:
1044/// `arborium`'s `HtmlFormat::ClassNames` spans, matched by
1045/// `crate::assets::OVERRIDES`'s `.code .keyword`-family rules.
1046#[tokio::test]
1047async fn files_blob_view_syntax_highlights_a_rust_file() {
1048 let dir = seed_repo(&[("src/main.rs", "fn main() {\n let ok = 1 < 2;\n}\n")]);
1049 let state = build_state_at(
1050 FixtureIdentity {
1051 name: "local-user",
1052 key: Keypair::from_seed(1),
1053 },
1054 dir.path().to_owned(),
1055 );
1056 let router = ents_web::router(state);
1057
1058 let response = router
1059 .oneshot(
1060 Request::get("/files/src/main.rs")
1061 .body(Body::empty())
1062 .expect("request"),
1063 )
1064 .await
1065 .expect("in-process call");
1066 assert_eq!(response.status(), StatusCode::OK);
1067 let body = response
1068 .into_body()
1069 .collect()
1070 .await
1071 .expect("body")
1072 .to_bytes();
1073 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1074 assert!(body.contains("blob-nums"));
1075 assert!(body.contains("class=\"code\""));
1076 assert!(body.contains("class=\"keyword\""));
1077}
1078
1079/// The icon rail (`crate::pages::layout_shell`) names every top-level page
1080/// family truthfully: Dashboard, Code, Review, Issues, Threads, then the
1081/// meta and account items -- and the issues family renders as its own rail
1082/// item, never behind the `META_SECTIONS` rail (see `crate::pages::mod`'s
1083/// own doc).
1084#[tokio::test]
1085async fn the_rail_carries_every_page_family_and_issues_left_the_meta_rail() {
1086 let state = build_state(FixtureIdentity {
1087 name: "local-user",
1088 key: Keypair::from_seed(1),
1089 });
1090 let router = ents_web::router(state);
1091
1092 let overview = get_body(&router, "/").await;
1093 for href in [
1094 "/",
1095 "/files",
1096 "/commits",
1097 "/reviews",
1098 "/issues",
1099 "/comments",
1100 "/meta",
1101 "/account",
1102 ] {
1103 assert!(
1104 overview.contains(&format!("href=\"{href}\"")),
1105 "the rail links {href}"
1106 );
1107 }
1108 for label in [
1109 "Dashboard", "Code", "Commits", "Reviews", "Issues", "Threads",
1110 ] {
1111 assert!(
1112 overview.contains(&format!("title=\"{label}\"")),
1113 "the rail tooltips {label}"
1114 );
1115 }
1116
1117 let issues = get_body(&router, "/issues").await;
1118 assert!(
1119 !issues.contains("class=\"meta-rail\""),
1120 "issues renders as its own rail item, not behind the meta rail"
1121 );
1122
1123 // The meta rail renders a bare (classless when inactive) link per
1124 // section; the icon rail's own issues link always carries a `title`
1125 // attribute, so this exact form only ever comes from the meta rail.
1126 let members = get_body(&router, "/members").await;
1127 assert!(
1128 !members.contains("<a href=\"/issues\">issues</a>"),
1129 "the meta rail no longer lists issues"
1130 );
1131}
1132
1133/// The rail highlights exactly the item whose page family is being viewed
1134/// (`crate::pages::rail_link`'s `active` toggle): on `GET /files` the Code
1135/// item carries `class="active"` and the others do not.
1136#[tokio::test]
1137async fn the_rail_marks_the_active_item() {
1138 // A real on-disk repository: `GET /files` opens `state.path` itself.
1139 let dir = seed_repo(&[("README.md", "# hi\n")]);
1140 let state = build_state_at(
1141 FixtureIdentity {
1142 name: "local-user",
1143 key: Keypair::from_seed(1),
1144 },
1145 dir.path().to_owned(),
1146 );
1147 let router = ents_web::router(state);
1148
1149 let files = get_body(&router, "/files").await;
1150 assert!(
1151 files.contains("class=\"active\" href=\"/files\""),
1152 "the Code item highlights on a files page"
1153 );
1154 assert!(
1155 files.contains("class=\"\" href=\"/commits\""),
1156 "the Review item stays unhighlighted there"
1157 );
1158
1159 let comments = get_body(&router, "/comments").await;
1160 assert!(
1161 comments.contains("class=\"active\" href=\"/comments\""),
1162 "the Threads item highlights on the comments page"
1163 );
1164 assert!(
1165 comments.contains("class=\"\" href=\"/files\""),
1166 "the Code item stays unhighlighted there"
1167 );
1168}
1169
1170/// The `meta` group (`crate::pages::mod`'s own doc): `GET /meta` is
1171/// reachable as the group's index page, and `GET /members` -- one of the
1172/// five page families that group shares -- renders with the
1173/// `META_SECTIONS` rail visible and the icon rail's meta item (not a
1174/// per-family item) highlighted.
1175#[tokio::test]
1176async fn meta_index_and_a_meta_group_page_render_with_the_rail() {
1177 let state = build_state(FixtureIdentity {
1178 name: "local-user",
1179 key: Keypair::from_seed(1),
1180 });
1181 let router = ents_web::router(state);
1182
1183 let meta_response = router
1184 .clone()
1185 .oneshot(Request::get("/meta").body(Body::empty()).expect("request"))
1186 .await
1187 .expect("in-process call");
1188 assert_eq!(meta_response.status(), StatusCode::OK);
1189
1190 let members_response = router
1191 .oneshot(
1192 Request::get("/members")
1193 .body(Body::empty())
1194 .expect("request"),
1195 )
1196 .await
1197 .expect("in-process call");
1198 assert_eq!(members_response.status(), StatusCode::OK);
1199 let body = members_response
1200 .into_body()
1201 .collect()
1202 .await
1203 .expect("body")
1204 .to_bytes();
1205 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1206 assert!(
1207 body.contains("class=\"meta-rail\""),
1208 "a meta-group page renders the section rail"
1209 );
1210 assert!(
1211 body.contains("class=\"active\" href=\"/meta\""),
1212 "the rail's meta item itself highlights, not a per-family item"
1213 );
1214}
1215
1216/// `GET /members` and `GET /members/{username}` render an identity card
1217/// per member -- username prominent, the key type as a badge, the key
1218/// material truncated through the middle with the full line behind a
1219/// details toggle -- never the generic entity table an SSH key's base64
1220/// body used to shred.
1221#[tokio::test]
1222async fn members_pages_render_an_identity_card_per_member() {
1223 let refs = MemRefStore::default();
1224 let objects = ObjectStore::default();
1225 let key = Keypair::from_seed(1);
1226 let full_key = key.public_openssh();
1227 enroll_member(
1228 &refs,
1229 &objects,
1230 "jdc",
1231 &key,
1232 Provenance::AdminRegistered,
1233 100,
1234 );
1235 let state = build_state_with(
1236 FixtureIdentity {
1237 name: "local-user",
1238 key: Keypair::from_seed(2),
1239 },
1240 refs,
1241 objects,
1242 );
1243 let router = ents_web::router(state);
1244
1245 let list = get_body(&router, "/members").await;
1246 assert!(list.contains("member-card"), "the identity card renders");
1247 assert!(
1248 list.contains("class=\"key-badge\"") && list.contains("ssh-ed25519"),
1249 "the key type badges"
1250 );
1251 assert!(
1252 list.contains('\u{2026}'),
1253 "the key material truncates through the middle"
1254 );
1255 assert!(
1256 list.contains("full key") && list.contains(&full_key),
1257 "the full key line stays one details toggle away"
1258 );
1259 assert!(
1260 !list.contains("entity-list"),
1261 "the generic table no longer renders here"
1262 );
1263
1264 let show = get_body(&router, "/members/jdc").await;
1265 assert!(show.contains("member-card"));
1266 assert!(show.contains("class=\"key-badge\""));
1267}
1268
1269/// `GET /files/<path>` renders a `.md` blob as Markdown and a `.adoc` blob
1270/// as AsciiDoc -- both a real rendered heading, not the raw source markup.
1271#[tokio::test]
1272async fn files_blob_view_renders_markdown_and_asciidoc_as_documents() {
1273 let dir = seed_repo(&[
1274 ("README.adoc", "= Welcome\n\nHello.\n"),
1275 ("docs/x.md", "# Doc Title\n\nSome text.\n"),
1276 ]);
1277 let state = build_state_at(
1278 FixtureIdentity {
1279 name: "local-user",
1280 key: Keypair::from_seed(1),
1281 },
1282 dir.path().to_owned(),
1283 );
1284 let router = ents_web::router(state);
1285
1286 let adoc_response = router
1287 .clone()
1288 .oneshot(
1289 Request::get("/files/README.adoc")
1290 .body(Body::empty())
1291 .expect("request"),
1292 )
1293 .await
1294 .expect("in-process call");
1295 assert_eq!(adoc_response.status(), StatusCode::OK);
1296 let adoc_body = adoc_response
1297 .into_body()
1298 .collect()
1299 .await
1300 .expect("body")
1301 .to_bytes();
1302 let adoc_body = String::from_utf8(adoc_body.to_vec()).expect("utf8 html");
1303 assert!(adoc_body.contains("<h1>Welcome</h1>"));
1304 assert!(!adoc_body.contains("= Welcome"));
1305
1306 let md_response = router
1307 .oneshot(
1308 Request::get("/files/docs/x.md")
1309 .body(Body::empty())
1310 .expect("request"),
1311 )
1312 .await
1313 .expect("in-process call");
1314 assert_eq!(md_response.status(), StatusCode::OK);
1315 let md_body = md_response
1316 .into_body()
1317 .collect()
1318 .await
1319 .expect("body")
1320 .to_bytes();
1321 let md_body = String::from_utf8(md_body.to_vec()).expect("utf8 html");
1322 assert!(md_body.contains("<h1>Doc Title</h1>"));
1323}
1324
1325/// `GET /files/<path>` on a blob with no comments carries no comment-card
1326/// markup at all -- not even an empty section (`crate::pages::comments::comments_section`'s
1327/// own no-drop-but-no-empty-section contract).
1328#[tokio::test]
1329async fn files_blob_view_with_no_comments_has_no_comment_card_markup() {
1330 let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
1331 let state = build_state_at(
1332 FixtureIdentity {
1333 name: "local-user",
1334 key: Keypair::from_seed(1),
1335 },
1336 dir.path().to_owned(),
1337 );
1338 let router = ents_web::router(state);
1339
1340 let response = router
1341 .oneshot(
1342 Request::get("/files/src/main.rs")
1343 .body(Body::empty())
1344 .expect("request"),
1345 )
1346 .await
1347 .expect("in-process call");
1348 assert_eq!(response.status(), StatusCode::OK);
1349 let body = response
1350 .into_body()
1351 .collect()
1352 .await
1353 .expect("body")
1354 .to_bytes();
1355 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1356 assert!(!body.contains("file-comments"));
1357 assert!(!body.contains("comment-meta"));
1358}
1359
1360/// A blob view shows every comment anchored to it: author, body (rendered
1361/// as AsciiDoc), and its projected line range linking into the blob's own
1362/// `#L<n>` gutter.
1363#[tokio::test]
1364async fn files_blob_view_shows_a_seeded_comments_body_and_author() {
1365 let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
1366 let state = build_state_at(
1367 FixtureIdentity {
1368 name: "commenter",
1369 key: Keypair::from_seed(1),
1370 },
1371 dir.path().to_owned(),
1372 );
1373 let router = ents_web::router(state.clone());
1374 seed_comment(
1375 &router,
1376 &state,
1377 "src/main.rs",
1378 "worth a look here",
1379 "2:2",
1380 "HEAD",
1381 )
1382 .await;
1383
1384 let response = router
1385 .oneshot(
1386 Request::get("/files/src/main.rs")
1387 .body(Body::empty())
1388 .expect("request"),
1389 )
1390 .await
1391 .expect("in-process call");
1392 assert_eq!(response.status(), StatusCode::OK);
1393 let body = response
1394 .into_body()
1395 .collect()
1396 .await
1397 .expect("body")
1398 .to_bytes();
1399 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1400 assert!(body.contains("id=\"comment-0\""));
1401 assert!(body.contains("worth a look here"));
1402 assert!(body.contains("commenter"));
1403 assert!(body.contains("href=\"#L2\""));
1404 assert!(!body.contains("class=\"outdated\""));
1405 // Interleaved directly into the blob, after line 2's row and before
1406 // line 3's -- not below the whole table.
1407 let line2 = body.find("id=\"L2\"").expect("line 2 renders");
1408 let card = body.find("comment-meta").expect("card renders");
1409 let line3 = body.find("id=\"L3\"").expect("line 3 renders");
1410 assert!(
1411 line2 < card && card < line3,
1412 "the card must land between line 2 and line 3, in document order"
1413 );
1414}
1415
1416/// A comment whose anchored lines were since edited still renders (never
1417/// dropped), flagged with the muted `outdated` marker instead of a line
1418/// link (`ents_anchor::Projection::Outdated`).
1419#[tokio::test]
1420async fn files_blob_view_marks_an_outdated_comment() {
1421 let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
1422 let state = build_state_at(
1423 FixtureIdentity {
1424 name: "commenter",
1425 key: Keypair::from_seed(1),
1426 },
1427 dir.path().to_owned(),
1428 );
1429 let router = ents_web::router(state.clone());
1430 seed_comment(
1431 &router,
1432 &state,
1433 "src/main.rs",
1434 "line two looks off",
1435 "2:2",
1436 "HEAD",
1437 )
1438 .await;
1439 // Edit exactly the anchored line, so the projection can no longer map
1440 // it -- `ents_anchor::project`'s own `Outdated` case.
1441 commit_change(
1442 dir.path(),
1443 "src/main.rs",
1444 "line 1\nsomething else entirely\nline 3\n",
1445 "edit line two",
1446 );
1447
1448 let response = router
1449 .oneshot(
1450 Request::get("/files/src/main.rs")
1451 .body(Body::empty())
1452 .expect("request"),
1453 )
1454 .await
1455 .expect("in-process call");
1456 assert_eq!(response.status(), StatusCode::OK);
1457 let body = response
1458 .into_body()
1459 .collect()
1460 .await
1461 .expect("body")
1462 .to_bytes();
1463 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1464 assert!(
1465 body.contains("line two looks off"),
1466 "comment is never dropped"
1467 );
1468 assert!(body.contains("class=\"outdated\""));
1469}
1470
1471/// `model.comment-state`, `roots.web-session`: a comment resolves and
1472/// reopens through CSRF-checked, signed `POST`s -- each an
1473/// `ents_forge::comment::{resolve,reopen}` call -- and its `GET
1474/// /comments/{id}` page reflects the new state and offers the opposite
1475/// action each time. The wrong CSRF token is refused, exactly as every
1476/// other state-changing route in this crate refuses one.
1477#[tokio::test]
1478// @relation(model.comment-state, roots.web-signing, roots.web-session, scope=function, role=Verifies)
1479async fn a_comment_resolves_and_reopens_through_csrf_checked_posts() {
1480 let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
1481 let state = build_state_at(
1482 FixtureIdentity {
1483 name: "commenter",
1484 key: Keypair::from_seed(1),
1485 },
1486 dir.path().to_owned(),
1487 );
1488 let router = ents_web::router(state.clone());
1489 let id = seed_comment(&router, &state, "src/main.rs", "look here", "2:2", "HEAD").await;
1490 let page = format!("/comments/{id}");
1491 let (cookie, csrf) = session_cookie_and_csrf(&router, &state, &page).await;
1492
1493 // A fresh comment lists open and offers "resolve".
1494 let body = get_body(&router, &page).await;
1495 assert!(body.contains("resolve"), "an open comment offers resolve");
1496
1497 // The wrong token is refused.
1498 let refused = router
1499 .clone()
1500 .oneshot(
1501 Request::post(format!("/comments/{id}/resolve"))
1502 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1503 .header(header::COOKIE, cookie.clone())
1504 .body(Body::from("csrf=not-the-token"))
1505 .expect("request"),
1506 )
1507 .await
1508 .expect("in-process call");
1509 assert_eq!(refused.status(), StatusCode::BAD_REQUEST);
1510
1511 // The right token resolves it.
1512 let resolved = router
1513 .clone()
1514 .oneshot(
1515 Request::post(format!("/comments/{id}/resolve"))
1516 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1517 .header(header::COOKIE, cookie.clone())
1518 .body(Body::from(format!("csrf={csrf}")))
1519 .expect("request"),
1520 )
1521 .await
1522 .expect("in-process call");
1523 assert!(resolved.status().is_redirection());
1524 let body = get_body(&router, &page).await;
1525 assert!(body.contains("resolved"), "the comment now reads resolved");
1526 assert!(body.contains("reopen"), "a resolved comment offers reopen");
1527
1528 // Reopen returns it to open.
1529 let reopened = router
1530 .clone()
1531 .oneshot(
1532 Request::post(format!("/comments/{id}/reopen"))
1533 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1534 .header(header::COOKIE, cookie)
1535 .body(Body::from(format!("csrf={csrf}")))
1536 .expect("request"),
1537 )
1538 .await
1539 .expect("in-process call");
1540 assert!(reopened.status().is_redirection());
1541 let body = get_body(&router, &page).await;
1542 assert!(
1543 body.contains(">open<") || body.contains("resolve"),
1544 "the comment offers resolve again once reopened"
1545 );
1546}
1547
1548/// `model.comment-thread`: a reply through `POST /comments/{id}/reply` is a
1549/// second comment (`ents_forge::comment::reply`) -- after it lands, the
1550/// comment index lists two comments where the seed left one.
1551#[tokio::test]
1552// @relation(model.comment-thread, roots.web-signing, roots.web-session, scope=function, role=Verifies)
1553async fn a_reply_creates_a_threaded_comment_through_a_signed_post() {
1554 let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
1555 let state = build_state_at(
1556 FixtureIdentity {
1557 name: "commenter",
1558 key: Keypair::from_seed(1),
1559 },
1560 dir.path().to_owned(),
1561 );
1562 let router = ents_web::router(state.clone());
1563 let id = seed_comment(&router, &state, "src/main.rs", "the parent", "2:2", "HEAD").await;
1564 let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/comments").await;
1565
1566 let reply = router
1567 .clone()
1568 .oneshot(
1569 Request::post(format!("/comments/{id}/reply"))
1570 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1571 .header(header::COOKIE, cookie)
1572 .body(Body::from(format!("body=a+reply+here&csrf={csrf}")))
1573 .expect("request"),
1574 )
1575 .await
1576 .expect("in-process call");
1577 assert!(reply.status().is_redirection(), "{:?}", reply.status());
1578
1579 let body = get_body(&router, "/comments").await;
1580 let count = body.matches("/comments/").count();
1581 assert!(
1582 count >= 2,
1583 "the index lists the parent and its reply, got {count} links"
1584 );
1585 assert!(body.contains("a reply here"), "the reply's body renders");
1586}
1587
1588/// A raw-source blob view carries the client-side hooks `assets/ents.js`
1589/// needs: `div.blob`'s own `data-path`/`data-rev` (the latter a full
1590/// 40-hex `HEAD` commit oid, not the string `"HEAD"`), and a
1591/// `<template id="composer-template">` whose form carries a csrf input and
1592/// hidden `path`/`rev` inputs pre-filled with this exact file and commit.
1593#[tokio::test]
1594async fn files_blob_view_carries_data_path_data_rev_and_the_composer_template() {
1595 let dir = seed_repo(&[("src/main.rs", "fn main() {}\n")]);
1596 let oid = head_oid(dir.path());
1597 assert_eq!(oid.len(), 40, "a full sha1 hex oid is 40 characters");
1598 let state = build_state_at(
1599 FixtureIdentity {
1600 name: "local-user",
1601 key: Keypair::from_seed(1),
1602 },
1603 dir.path().to_owned(),
1604 );
1605 let router = ents_web::router(state);
1606
1607 let response = router
1608 .oneshot(
1609 Request::get("/files/src/main.rs")
1610 .body(Body::empty())
1611 .expect("request"),
1612 )
1613 .await
1614 .expect("in-process call");
1615 assert_eq!(response.status(), StatusCode::OK);
1616 let body = response
1617 .into_body()
1618 .collect()
1619 .await
1620 .expect("body")
1621 .to_bytes();
1622 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1623 assert!(body.contains("data-path=\"src/main.rs\""));
1624 assert!(body.contains(&format!("data-rev=\"{oid}\"")));
1625 let template_start = body
1626 .find("id=\"composer-template\"")
1627 .expect("composer template renders");
1628 let template = body.get(template_start..).expect("template slice");
1629 assert!(
1630 template.contains("name=\"csrf\""),
1631 "the composer's own form carries a csrf input"
1632 );
1633 assert!(template.contains(r#"name="path" value="src/main.rs""#));
1634 assert!(template.contains(&format!(r#"name="rev" value="{oid}""#)));
1635}
1636
1637/// `GET /ents.js` serves the client-side script `crate::pages::layout`
1638/// loads with `defer` -- no session required (mirrors `GET /style.css`'s
1639/// own stance), a JS content type, and a non-empty body.
1640#[tokio::test]
1641async fn ents_js_is_served_with_a_javascript_content_type() {
1642 let state = build_state(FixtureIdentity {
1643 name: "local-user",
1644 key: Keypair::from_seed(1),
1645 });
1646 let router = ents_web::router(state);
1647
1648 let response = router
1649 .oneshot(
1650 Request::get("/ents.js")
1651 .body(Body::empty())
1652 .expect("request"),
1653 )
1654 .await
1655 .expect("in-process call");
1656 assert_eq!(response.status(), StatusCode::OK);
1657 let content_type = response
1658 .headers()
1659 .get(header::CONTENT_TYPE)
1660 .expect("content-type")
1661 .to_str()
1662 .expect("ascii")
1663 .to_owned();
1664 assert!(content_type.contains("javascript"));
1665 let body = response
1666 .into_body()
1667 .collect()
1668 .await
1669 .expect("body")
1670 .to_bytes();
1671 assert!(!body.is_empty());
1672}
1673
1674/// The blob header bar (`crate::pages::files::blob_header`) shows a raw
1675/// source file's line count, human-formatted size, and detected language,
1676/// plus the "comment on this file" no-JS fallback link -- moved here from
1677/// `crumbs`'s own trailing edge.
1678#[tokio::test]
1679async fn files_blob_header_shows_line_count_size_language_and_the_comment_link() {
1680 let dir = seed_repo(&[("src/main.rs", "fn main() {\n let ok = 1;\n}\n")]);
1681 let state = build_state_at(
1682 FixtureIdentity {
1683 name: "local-user",
1684 key: Keypair::from_seed(1),
1685 },
1686 dir.path().to_owned(),
1687 );
1688 let router = ents_web::router(state);
1689
1690 let response = router
1691 .oneshot(
1692 Request::get("/files/src/main.rs")
1693 .body(Body::empty())
1694 .expect("request"),
1695 )
1696 .await
1697 .expect("in-process call");
1698 assert_eq!(response.status(), StatusCode::OK);
1699 let body = response
1700 .into_body()
1701 .collect()
1702 .await
1703 .expect("body")
1704 .to_bytes();
1705 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1706 assert!(body.contains("blob-header"));
1707 assert!(body.contains("3 lines"));
1708 assert!(body.contains("rust"));
1709 assert!(body.contains("comment on this file"));
1710}
1711
1712/// `GET /files` (a directory listing): a file entry carries a
1713/// human-formatted size (`span.entry-size`), rendered after the
1714/// directory-first entries, which carry none.
1715#[tokio::test]
1716async fn files_root_listing_shows_a_size_for_a_file_but_not_a_directory() {
1717 let dir = seed_repo(&[("README.md", "# hi\n"), ("src/main.rs", "fn main() {}\n")]);
1718 let state = build_state_at(
1719 FixtureIdentity {
1720 name: "local-user",
1721 key: Keypair::from_seed(1),
1722 },
1723 dir.path().to_owned(),
1724 );
1725 let router = ents_web::router(state);
1726
1727 let response = router
1728 .oneshot(Request::get("/files").body(Body::empty()).expect("request"))
1729 .await
1730 .expect("in-process call");
1731 assert_eq!(response.status(), StatusCode::OK);
1732 let body = response
1733 .into_body()
1734 .collect()
1735 .await
1736 .expect("body")
1737 .to_bytes();
1738 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1739 let src_index = body
1740 .find("/files/src\"")
1741 .expect("the src directory links in");
1742 let size_index = body.find("entry-size").expect("a size span renders");
1743 assert!(
1744 src_index < size_index,
1745 "the directory row (sorted first, no size cell) renders before the file row's own size"
1746 );
1747}
1748
1749/// A doc-rendered (Markdown) blob view still carries the whole-file
1750/// composer template: there is no per-line gutter row for `assets/ents.js`
1751/// to open one against a specific line range, but "comment on this file"
1752/// (the template's own empty `lines` default) is exactly as meaningful on
1753/// a rendered document as on a raw-source view.
1754#[tokio::test]
1755async fn files_markdown_blob_view_carries_the_composer_template() {
1756 let dir = seed_repo(&[("docs/x.md", "# Doc Title\n\nSome text.\n")]);
1757 let state = build_state_at(
1758 FixtureIdentity {
1759 name: "local-user",
1760 key: Keypair::from_seed(1),
1761 },
1762 dir.path().to_owned(),
1763 );
1764 let router = ents_web::router(state);
1765
1766 let response = router
1767 .oneshot(
1768 Request::get("/files/docs/x.md")
1769 .body(Body::empty())
1770 .expect("request"),
1771 )
1772 .await
1773 .expect("in-process call");
1774 assert_eq!(response.status(), StatusCode::OK);
1775 let body = response
1776 .into_body()
1777 .collect()
1778 .await
1779 .expect("body")
1780 .to_bytes();
1781 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1782 assert!(
1783 body.contains("id=\"composer-template\""),
1784 "a doc-rendered view has no per-line gutter, but \"comment on this \
1785 file\" (empty `lines`) is exactly as meaningful there"
1786 );
1787}
1788
1789/// `GET /comments?file=<path>&lines=<range>` pre-fills the add-comment
1790/// form's `path`/`lines` fields -- the entry point `crate::pages::files`'s
1791/// own "comment on this file" link uses.
1792#[tokio::test]
1793async fn comments_list_prefills_the_add_form_from_query_params() {
1794 let state = build_state(FixtureIdentity {
1795 name: "local-user",
1796 key: Keypair::from_seed(1),
1797 });
1798 let router = ents_web::router(state);
1799
1800 let response = router
1801 .oneshot(
1802 Request::get("/comments?file=src/main.rs&lines=1-2")
1803 .body(Body::empty())
1804 .expect("request"),
1805 )
1806 .await
1807 .expect("in-process call");
1808 assert_eq!(response.status(), StatusCode::OK);
1809 let body = response
1810 .into_body()
1811 .collect()
1812 .await
1813 .expect("body")
1814 .to_bytes();
1815 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1816 assert!(body.contains(r#"name="path" value="src/main.rs""#));
1817 assert!(body.contains(r#"name="lines" value="1-2""#));
1818 assert!(
1819 body.contains(r#"name="rev" value="HEAD""#),
1820 "rev defaults to HEAD when absent, exactly as before"
1821 );
1822}
1823
1824/// `GET /comments?rev=<oid>` (the link `crate::pages::commits::show`'s
1825/// "comment on this commit" renders) carries the given rev through into
1826/// the add form, rather than defaulting to `HEAD`.
1827#[tokio::test]
1828async fn comments_list_prefills_rev_from_the_query_param() {
1829 let state = build_state(FixtureIdentity {
1830 name: "local-user",
1831 key: Keypair::from_seed(1),
1832 });
1833 let router = ents_web::router(state);
1834
1835 let response = router
1836 .oneshot(
1837 Request::get("/comments?rev=deadbeef")
1838 .body(Body::empty())
1839 .expect("request"),
1840 )
1841 .await
1842 .expect("in-process call");
1843 assert_eq!(response.status(), StatusCode::OK);
1844 let body = response
1845 .into_body()
1846 .collect()
1847 .await
1848 .expect("body")
1849 .to_bytes();
1850 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1851 assert!(body.contains(r#"name="rev" value="deadbeef""#));
1852}
1853
1854/// `GET /commits` lists the repository's commit history: the seeded
1855/// commit's own short id appears, linking into `/commit/{oid}`.
1856#[tokio::test]
1857async fn commits_list_shows_a_fixture_commit() {
1858 let dir = seed_repo(&[("README.md", "# hi\n")]);
1859 let oid = head_oid(dir.path());
1860 let state = build_state_at(
1861 FixtureIdentity {
1862 name: "local-user",
1863 key: Keypair::from_seed(1),
1864 },
1865 dir.path().to_owned(),
1866 );
1867 let router = ents_web::router(state);
1868
1869 let response = router
1870 .oneshot(
1871 Request::get("/commits")
1872 .body(Body::empty())
1873 .expect("request"),
1874 )
1875 .await
1876 .expect("in-process call");
1877 assert_eq!(response.status(), StatusCode::OK);
1878 let body = response
1879 .into_body()
1880 .collect()
1881 .await
1882 .expect("body")
1883 .to_bytes();
1884 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1885 assert!(body.contains(&format!("/commit/{oid}")));
1886 assert!(body.contains("seed"), "the seeded commit's subject renders");
1887}
1888
1889/// `GET /commit/{oid}` shows the commit's subject, its author, and a
1890/// colorized diff line for the file it introduced.
1891#[tokio::test]
1892async fn commit_show_renders_the_subject_and_a_diff_line() {
1893 let dir = seed_repo(&[("README.md", "# hi\n")]);
1894 let oid = head_oid(dir.path());
1895 let state = build_state_at(
1896 FixtureIdentity {
1897 name: "local-user",
1898 key: Keypair::from_seed(1),
1899 },
1900 dir.path().to_owned(),
1901 );
1902 let router = ents_web::router(state);
1903
1904 let response = router
1905 .oneshot(
1906 Request::get(format!("/commit/{oid}"))
1907 .body(Body::empty())
1908 .expect("request"),
1909 )
1910 .await
1911 .expect("in-process call");
1912 assert_eq!(response.status(), StatusCode::OK);
1913 let body = response
1914 .into_body()
1915 .collect()
1916 .await
1917 .expect("body")
1918 .to_bytes();
1919 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1920 assert!(body.contains("seed"), "the commit's subject renders");
1921 assert!(
1922 body.contains("class=\"ln add\""),
1923 "the root commit's diff renders its added lines"
1924 );
1925}
1926
1927/// `GET /commit/{oid}` renders one `.file` header per changed blob and
1928/// none for the intermediate directories the tree walk also names --
1929/// each subdirectory used to appear as its own bare file section.
1930#[tokio::test]
1931async fn commit_diff_lists_files_not_intermediate_directories() {
1932 let dir = seed_repo(&[("crates/foo/src/lib.rs", "pub fn f() {}\n")]);
1933 let oid = head_oid(dir.path());
1934 let state = build_state_at(
1935 FixtureIdentity {
1936 name: "local-user",
1937 key: Keypair::from_seed(1),
1938 },
1939 dir.path().to_owned(),
1940 );
1941 let router = ents_web::router(state);
1942
1943 let response = router
1944 .oneshot(
1945 Request::get(format!("/commit/{oid}"))
1946 .body(Body::empty())
1947 .expect("request"),
1948 )
1949 .await
1950 .expect("in-process call");
1951 assert_eq!(response.status(), StatusCode::OK);
1952 let body = response
1953 .into_body()
1954 .collect()
1955 .await
1956 .expect("body")
1957 .to_bytes();
1958 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
1959 assert_eq!(
1960 body.matches("class=\"ln file\"").count(),
1961 1,
1962 "one changed blob means exactly one file header"
1963 );
1964}
1965
1966/// `GET /commit/{oid}` lists, under a "conversation" heading, every
1967/// comment whose anchor was captured against that exact commit -- and
1968/// none captured against a different one, even a later commit on the same
1969/// branch. The "comment on this commit" link prefills `rev` to the shown
1970/// commit's own oid.
1971#[tokio::test]
1972async fn commit_show_lists_comments_captured_against_that_exact_commit() {
1973 let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
1974 let first_oid = head_oid(dir.path());
1975 let state = build_state_at(
1976 FixtureIdentity {
1977 name: "commenter",
1978 key: Keypair::from_seed(1),
1979 },
1980 dir.path().to_owned(),
1981 );
1982 let router = ents_web::router(state.clone());
1983 seed_comment(
1984 &router,
1985 &state,
1986 "src/main.rs",
1987 "left at the first commit",
1988 "2:2",
1989 &first_oid,
1990 )
1991 .await;
1992
1993 commit_change(
1994 dir.path(),
1995 "src/main.rs",
1996 "line 1\nline two\nline 3\n",
1997 "second commit",
1998 );
1999 let second_oid = head_oid(dir.path());
2000 assert_ne!(first_oid, second_oid);
2001
2002 let first_response = router
2003 .clone()
2004 .oneshot(
2005 Request::get(format!("/commit/{first_oid}"))
2006 .body(Body::empty())
2007 .expect("request"),
2008 )
2009 .await
2010 .expect("in-process call");
2011 assert_eq!(first_response.status(), StatusCode::OK);
2012 let first_body = String::from_utf8(
2013 first_response
2014 .into_body()
2015 .collect()
2016 .await
2017 .expect("body")
2018 .to_bytes()
2019 .to_vec(),
2020 )
2021 .expect("utf8 html");
2022 assert!(first_body.contains("Conversation"));
2023 assert!(first_body.contains("left at the first commit"));
2024 assert!(first_body.contains("commenter"));
2025 assert!(
2026 first_body.contains("href=\"/files/src/main.rs#L2\""),
2027 "the conversation card links path#lines into the file browser: {first_body}"
2028 );
2029 assert!(
2030 first_body.contains(&format!("href=\"/comments?rev={first_oid}\"")),
2031 "the comment-on-this-commit link prefills this commit's own oid: {first_body}"
2032 );
2033
2034 let second_response = router
2035 .oneshot(
2036 Request::get(format!("/commit/{second_oid}"))
2037 .body(Body::empty())
2038 .expect("request"),
2039 )
2040 .await
2041 .expect("in-process call");
2042 assert_eq!(second_response.status(), StatusCode::OK);
2043 let second_body = String::from_utf8(
2044 second_response
2045 .into_body()
2046 .collect()
2047 .await
2048 .expect("body")
2049 .to_bytes()
2050 .to_vec(),
2051 )
2052 .expect("utf8 html");
2053 assert!(
2054 !second_body.contains("left at the first commit"),
2055 "a comment captured against the first commit must not appear on the second: {second_body}"
2056 );
2057}
2058
2059/// `model.review`, `model.comment-context`: starting a review on a commit
2060/// page (`POST /commit/{oid}/review`, `ents_forge::review::new`) makes its
2061/// verdict, body, and reviewer render on that commit's page, and a comment
2062/// on the review (`POST /reviews/{id}/comment`) joins the review's own
2063/// discussion thread -- every step a CSRF-checked signed POST through the
2064/// injected identity.
2065#[tokio::test]
2066// @relation(model.review, model.review-pin, model.comment-context, roots.web-signing, roots.web-session, scope=function, role=Verifies)
2067async fn commit_page_shows_a_seeded_review_verdict_and_a_review_comment() {
2068 let dir = seed_repo(&[("src/main.rs", "fn main() {}\n")]);
2069 let oid = head_oid(dir.path());
2070 let state = build_state_at(
2071 FixtureIdentity {
2072 name: "reviewer",
2073 key: Keypair::from_seed(1),
2074 },
2075 dir.path().to_owned(),
2076 );
2077 let router = ents_web::router(state.clone());
2078
2079 // Start a review of this commit through the commit page's own form.
2080 let (cookie, csrf) = session_cookie_and_csrf(&router, &state, &format!("/commit/{oid}")).await;
2081 let started = router
2082 .clone()
2083 .oneshot(
2084 Request::post(format!("/commit/{oid}/review"))
2085 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2086 .header(header::COOKIE, cookie.clone())
2087 .body(Body::from(format!(
2088 "verdict=request-changes&body=needs+a+test&csrf={csrf}"
2089 )))
2090 .expect("request"),
2091 )
2092 .await
2093 .expect("in-process call");
2094 assert!(started.status().is_redirection(), "{:?}", started.status());
2095
2096 // The commit page renders the review's verdict, body, and reviewer.
2097 let page = get_body(&router, &format!("/commit/{oid}")).await;
2098 assert!(page.contains("Reviews"), "the reviews section renders");
2099 assert!(
2100 page.contains("class=\"verdict verdict-request-changes\""),
2101 "the verdict renders prominently, colored by its value"
2102 );
2103 assert!(page.contains("request-changes"), "the verdict text renders");
2104 assert!(page.contains("needs a test"), "the review body renders");
2105 assert!(
2106 page.contains("reviewer"),
2107 "the reviewer's own identity (from the commit chain) renders"
2108 );
2109
2110 // Recover the review id from its comment form's action, then comment on
2111 // the review; the comment joins the review's thread on the same page.
2112 let review_id = page
2113 .split_once("/reviews/")
2114 .and_then(|(_, rest)| rest.split_once("/comment"))
2115 .map(|(id, _)| id)
2116 .expect("a review comment form links in");
2117
2118 let commented = router
2119 .clone()
2120 .oneshot(
2121 Request::post(format!("/reviews/{review_id}/comment"))
2122 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2123 .header(header::COOKIE, cookie)
2124 .body(Body::from(format!(
2125 "body=agreed+on+the+test&return_to=/commit/{oid}&csrf={csrf}"
2126 )))
2127 .expect("request"),
2128 )
2129 .await
2130 .expect("in-process call");
2131 assert!(
2132 commented.status().is_redirection(),
2133 "{:?}",
2134 commented.status()
2135 );
2136 let page = get_body(&router, &format!("/commit/{oid}")).await;
2137 assert!(
2138 page.contains("agreed on the test"),
2139 "the review comment renders in the review's thread: {page}"
2140 );
2141}
2142
2143/// The commit page's Checks card (`model.result-identity`,
2144/// `model.result-taxonomy`): a recorded result whose stored target names
2145/// the shown commit renders as a status chip and its effect's link --
2146/// one row per taxonomy value here -- a self-run mirror row additionally
2147/// names its member, and a result targeting a different commit stays off
2148/// the page (the tree's own `target` field is what is matched, not the
2149/// refname).
2150#[tokio::test]
2151async fn commit_page_lists_recorded_results_as_checks() {
2152 let dir = seed_repo(&[("src/main.rs", "fn main() {}\n")]);
2153 let oid = head_oid(dir.path());
2154 let refs = MemRefStore::default();
2155 let objects = ObjectStore::default();
2156 record_result(&refs, &objects, "unit", &oid, Status::Pass, None, 1_000);
2157 record_result(&refs, &objects, "lint", &oid, Status::Fail, None, 1_001);
2158 record_result(&refs, &objects, "deploy", &oid, Status::Error, None, 1_002);
2159 // Same effect, different target commit: filtered out by the stored
2160 // target field.
2161 record_result(
2162 &refs,
2163 &objects,
2164 "unit",
2165 "aaaaaaaa",
2166 Status::Pass,
2167 None,
2168 1_003,
2169 );
2170 // A self-run mirror row names the member that ran it.
2171 let member = MemberId::new("joey");
2172 let target = gix::ObjectId::from_hex(oid.as_bytes()).expect("head oid is hex");
2173 let self_ref = ents_model::namespace::self_result_ref(&member, "unit", &oid).expect("valid");
2174 let mirror = ResultRecord::new("unit", target, Status::Pass);
2175 write_meta_entity(&refs, &objects, self_ref, &mirror, None, 1_004);
2176
2177 let state = Arc::new(AppState::new(
2178 Box::new(refs),
2179 objects,
2180 Box::new(NullEventSink),
2181 Mode::Advisory,
2182 Box::new(FixtureIdentity {
2183 name: "local-user",
2184 key: Keypair::from_seed(1),
2185 }),
2186 dir.path().to_owned(),
2187 ));
2188 let router = ents_web::router(state);
2189
2190 let body = get_body(&router, &format!("/commit/{oid}")).await;
2191 assert!(body.contains("Checks"), "the Checks card renders");
2192 for (chip, effect) in [
2193 ("status-pass", "unit"),
2194 ("status-fail", "lint"),
2195 ("status-error", "deploy"),
2196 ] {
2197 assert!(
2198 body.contains(chip),
2199 "the {effect} row carries its {chip} chip"
2200 );
2201 assert!(
2202 body.contains(&format!("/effects/{effect}")),
2203 "the {effect} row links to its effect page"
2204 );
2205 }
2206 assert!(
2207 body.contains("self-run by joey"),
2208 "the mirror row names its member"
2209 );
2210 // The chip class appears once in the canonical unit row and once in
2211 // the self-run mirror row -- never for the other commit's result.
2212 assert_eq!(
2213 body.matches("status-pass").count(),
2214 2,
2215 "the other commit's result stays off the page"
2216 );
2217}
2218
2219/// `GET /commit/{oid}` on a malformed id is a 404, never a panic or 500.
2220#[tokio::test]
2221async fn commit_show_on_an_invalid_oid_is_not_found_not_a_crash() {
2222 let state = build_state(FixtureIdentity {
2223 name: "local-user",
2224 key: Keypair::from_seed(1),
2225 });
2226 let router = ents_web::router(state);
2227
2228 let response = router
2229 .oneshot(
2230 Request::get("/commit/zzz")
2231 .body(Body::empty())
2232 .expect("request"),
2233 )
2234 .await
2235 .expect("in-process call");
2236 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2237}
2238
2239/// `POST /issues` through the real signed-write path
2240/// (`ents_forge::issue::new`), returning the new issue's id from the
2241/// redirect's `Location` (`/issues/<id>`). Asserts the write succeeded.
2242async fn seed_issue(
2243 router: &axum::Router,
2244 state: &AppState<ObjectStore>,
2245 title: &str,
2246 issue_state: &str,
2247 assignees: &str,
2248 labels: &str,
2249) -> String {
2250 let (cookie, csrf) = session_cookie_and_csrf(router, state, "/issues").await;
2251 // Field names are `IssueAction::New`'s own (`ents_web::form`): the
2252 // form's controls and its parse both derive from the action shape.
2253 let form = format!(
2254 "title={}&state={issue_state}&assignee={assignees}&label={labels}&body=the+full+body&csrf={csrf}",
2255 title.replace(' ', "+")
2256 );
2257 let response = router
2258 .clone()
2259 .oneshot(
2260 Request::post("/issues")
2261 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2262 .header(header::COOKIE, cookie)
2263 .body(Body::from(form))
2264 .expect("request"),
2265 )
2266 .await
2267 .expect("in-process call");
2268 assert!(
2269 response.status().is_redirection(),
2270 "issue create did not succeed: {:?}",
2271 response.status()
2272 );
2273 response
2274 .headers()
2275 .get(header::LOCATION)
2276 .expect("a successful issue create redirects to the new issue")
2277 .to_str()
2278 .expect("ascii")
2279 .strip_prefix("/issues/")
2280 .expect("redirect targets /issues/<id>")
2281 .to_owned()
2282}
2283
2284/// `model.issue`, `model.comment-context`: the issues index lists a seeded
2285/// issue with its state, assignees, and labels; the detail page shows the
2286/// issue and its discussion thread; a comment naming `issues/<id>` as its
2287/// context joins that thread; and an edit changes the issue's state -- every
2288/// mutation a CSRF-checked signed POST calling the same `ents_forge` funcs
2289/// the CLI and lens do.
2290#[tokio::test]
2291// @relation(model.issue, model.comment-context, roots.web-signing, roots.web-session, scope=function, role=Verifies)
2292async fn issues_index_and_detail_render_a_seeded_issue_and_its_context_comment() {
2293 let state = build_state(FixtureIdentity {
2294 name: "filer",
2295 key: Keypair::from_seed(1),
2296 });
2297 let router = ents_web::router(state.clone());
2298 let id = seed_issue(
2299 &router,
2300 &state,
2301 "gate rejects a valid signature",
2302 "triaged",
2303 "jdc",
2304 "bug",
2305 )
2306 .await;
2307
2308 // The index lists the issue with its state, assignees, and labels, and
2309 // links into its detail page.
2310 let index = get_body(&router, "/issues").await;
2311 assert!(index.contains("gate rejects a valid signature"));
2312 assert!(index.contains("triaged"));
2313 assert!(index.contains("jdc"));
2314 assert!(index.contains("bug"));
2315 assert!(index.contains(&format!("/issues/{id}")));
2316
2317 // The detail page shows the issue and an (initially empty) discussion.
2318 let detail = get_body(&router, &format!("/issues/{id}")).await;
2319 assert!(detail.contains("gate rejects a valid signature"));
2320 assert!(detail.contains("the full body"));
2321 assert!(detail.contains("Discussion"));
2322
2323 // A comment naming the issue as its context joins the thread.
2324 let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/issues").await;
2325 let comment = router
2326 .clone()
2327 .oneshot(
2328 Request::post(format!("/issues/{id}/comment"))
2329 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2330 .header(header::COOKIE, cookie.clone())
2331 .body(Body::from(format!("body=cannot+reproduce+yet&csrf={csrf}")))
2332 .expect("request"),
2333 )
2334 .await
2335 .expect("in-process call");
2336 assert!(comment.status().is_redirection(), "{:?}", comment.status());
2337 let detail = get_body(&router, &format!("/issues/{id}")).await;
2338 assert!(
2339 detail.contains("cannot reproduce yet"),
2340 "the context comment renders in the issue's thread: {detail}"
2341 );
2342
2343 // Editing the issue's state lands and reads back.
2344 let edited = router
2345 .clone()
2346 .oneshot(
2347 Request::post(format!("/issues/{id}"))
2348 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2349 .header(header::COOKIE, cookie)
2350 .body(Body::from(format!("state=closed&csrf={csrf}")))
2351 .expect("request"),
2352 )
2353 .await
2354 .expect("in-process call");
2355 assert!(edited.status().is_redirection());
2356 let detail = get_body(&router, &format!("/issues/{id}")).await;
2357 assert!(detail.contains("closed"), "the edited state reads back");
2358}
2359
2360/// `roots.web-session`: opening an issue is a state-changing route, so a
2361/// `POST /issues` with no CSRF field at all is rejected, and one with the
2362/// wrong token is a bad request -- the same gate every mutation in this
2363/// crate runs behind.
2364#[tokio::test]
2365// @relation(model.issue, roots.web-session, scope=function, role=Verifies)
2366async fn issue_create_is_rejected_without_a_valid_csrf_token() {
2367 let state = build_state(FixtureIdentity {
2368 name: "filer",
2369 key: Keypair::from_seed(1),
2370 });
2371 let router = ents_web::router(Arc::clone(&state));
2372
2373 // No CSRF field at all.
2374 let no_csrf = router
2375 .clone()
2376 .oneshot(
2377 Request::post("/issues")
2378 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2379 .body(Body::from("title=sneaky&state=open"))
2380 .expect("request"),
2381 )
2382 .await
2383 .expect("in-process call");
2384 assert!(
2385 !no_csrf.status().is_success() && !no_csrf.status().is_redirection(),
2386 "a POST with no csrf field must not open an issue"
2387 );
2388
2389 // A session's cookie, but the wrong token.
2390 let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/issues").await;
2391 let wrong = router
2392 .oneshot(
2393 Request::post("/issues")
2394 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2395 .header(header::COOKIE, cookie)
2396 .body(Body::from("title=sneaky&state=open&csrf=not-the-token"))
2397 .expect("request"),
2398 )
2399 .await
2400 .expect("in-process call");
2401 assert_eq!(wrong.status(), StatusCode::BAD_REQUEST);
2402}
2403
2404/// `GET /search?q=` finds a known fixture file path, linking into its
2405/// `/files/...` blob view.
2406#[tokio::test]
2407async fn search_finds_a_known_fixture_file_path() {
2408 let dir = seed_repo(&[("src/needle.rs", "fn main() {}\n")]);
2409 let state = build_state_at(
2410 FixtureIdentity {
2411 name: "local-user",
2412 key: Keypair::from_seed(1),
2413 },
2414 dir.path().to_owned(),
2415 );
2416 let router = ents_web::router(state);
2417
2418 let response = router
2419 .oneshot(
2420 Request::get("/search?q=needle")
2421 .body(Body::empty())
2422 .expect("request"),
2423 )
2424 .await
2425 .expect("in-process call");
2426 assert_eq!(response.status(), StatusCode::OK);
2427 let body = response
2428 .into_body()
2429 .collect()
2430 .await
2431 .expect("body")
2432 .to_bytes();
2433 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
2434 assert!(body.contains("/files/src/needle.rs"));
2435}
2436
2437/// `GET /search` with no query renders a "type to search" blankslate
2438/// naming the header's own search input -- not a "no matches" one, since
2439/// nothing was searched yet -- rather than an empty or error page.
2440#[tokio::test]
2441async fn search_with_no_query_renders_a_blankslate() {
2442 let state = build_state(FixtureIdentity {
2443 name: "local-user",
2444 key: Keypair::from_seed(1),
2445 });
2446 let router = ents_web::router(state);
2447
2448 let response = router
2449 .oneshot(
2450 Request::get("/search")
2451 .body(Body::empty())
2452 .expect("request"),
2453 )
2454 .await
2455 .expect("in-process call");
2456 assert_eq!(response.status(), StatusCode::OK);
2457 let body = response
2458 .into_body()
2459 .collect()
2460 .await
2461 .expect("body")
2462 .to_bytes();
2463 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
2464 assert!(body.contains("Type to search"));
2465 assert!(
2466 body.contains("Jump to file or symbol"),
2467 "the prompt names the header's own search input"
2468 );
2469}
2470
2471/// `GET /comments` surfaces a comment ref written by an older schema
2472/// through the shared unreadable disclosure instead of silently dropping
2473/// it, and its own `GET /comments/{id}` page renders the plain unreadable
2474/// marker card rather than erroring.
2475#[tokio::test]
2476async fn comments_surface_an_unreadable_ref_in_the_list_and_on_its_own_page() {
2477 let refs = MemRefStore::default();
2478 let objects = ObjectStore::default();
2479 let tip = write_commit(
2480 &objects,
2481 &CommitSpec {
2482 tree: ents_testutil::empty_tree(&objects),
2483 parents: Vec::new(),
2484 message: "legacy comment".to_owned(),
2485 seconds: 100,
2486 },
2487 None,
2488 );
2489 let refname: gix::refs::FullName = "refs/meta/comments/legacy"
2490 .try_into()
2491 .expect("valid refname");
2492 refs.set(refname.as_ref(), tip);
2493
2494 let state = build_state_with(
2495 FixtureIdentity {
2496 name: "local-user",
2497 key: Keypair::from_seed(1),
2498 },
2499 refs,
2500 objects,
2501 );
2502 let router = ents_web::router(state);
2503
2504 let list = get_body(&router, "/comments").await;
2505 assert!(
2506 list.contains("unreadable-note") && list.contains("1 unreadable"),
2507 "the list page carries the subtle disclosure: {list}"
2508 );
2509 assert!(
2510 list.contains("refs/meta/comments/legacy"),
2511 "the disclosure names the failed ref"
2512 );
2513
2514 let detail = get_body(&router, "/comments/legacy").await;
2515 assert!(
2516 detail.contains("unreadable"),
2517 "the detail page shows the error state plainly instead of erroring: {detail}"
2518 );
2519}
2520
2521/// `POST /effects` defines an effect as a signed mutation on
2522/// `refs/meta/effects/<name>` and redirects to its show page; the list
2523/// page then names it -- the web counterpart of `git ents effect add`.
2524#[tokio::test]
2525async fn effect_form_defines_an_effect() {
2526 let dir = seed_repo(&[("README.md", "# hi\n")]);
2527 let state = build_state_at(
2528 FixtureIdentity {
2529 name: "local-user",
2530 key: Keypair::from_seed(1),
2531 },
2532 dir.path().to_owned(),
2533 );
2534 let router = ents_web::router(state.clone());
2535 let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/effects").await;
2536
2537 let form = format!(
2538 "name=unit&trigger=rev(refs/heads/main)&run=cargo+nextest+run&toolchains=rust&csrf={csrf}"
2539 );
2540 let response = router
2541 .clone()
2542 .oneshot(
2543 Request::post("/effects")
2544 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2545 .header(header::COOKIE, cookie)
2546 .body(Body::from(form))
2547 .expect("request"),
2548 )
2549 .await
2550 .expect("in-process call");
2551 assert!(
2552 response.status().is_redirection(),
2553 "effect write did not succeed: {:?}",
2554 response.status()
2555 );
2556
2557 let list = get_body(&router, "/effects").await;
2558 assert!(list.contains("unit"), "the new effect lists: {list}");
2559 let show = get_body(&router, "/effects/unit").await;
2560 assert!(
2561 show.contains("rev(refs/heads/main)") && show.contains("parses"),
2562 "the show page renders the trigger and its parse check: {show}"
2563 );
2564}
2565
2566/// `POST /toolchains` records a toolchain from a recipe given as text
2567/// (`ents_kiln::toolchain::register`) and redirects to its show page --
2568/// the recipe-flow counterpart of `git ents toolchain import`.
2569#[tokio::test]
2570async fn toolchain_form_registers_a_recipe() {
2571 let dir = seed_repo(&[("README.md", "# hi\n")]);
2572 let state = build_state_at(
2573 FixtureIdentity {
2574 name: "local-user",
2575 key: Keypair::from_seed(1),
2576 },
2577 dir.path().to_owned(),
2578 );
2579 let router = ents_web::router(state.clone());
2580 let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/toolchains").await;
2581
2582 let form =
2583 format!("name=empty&recipe=embedded+4b825dc642cb6eb9a060e54bf8d69288fbee4904&csrf={csrf}");
2584 let response = router
2585 .clone()
2586 .oneshot(
2587 Request::post("/toolchains")
2588 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2589 .header(header::COOKIE, cookie)
2590 .body(Body::from(form))
2591 .expect("request"),
2592 )
2593 .await
2594 .expect("in-process call");
2595 assert!(
2596 response.status().is_redirection(),
2597 "toolchain write did not succeed: {:?}",
2598 response.status()
2599 );
2600
2601 let list = get_body(&router, "/toolchains").await;
2602 assert!(list.contains("empty"), "the new toolchain lists: {list}");
2603 let show = get_body(&router, "/toolchains/empty").await;
2604 assert!(
2605 show.contains("Embedded"),
2606 "the show page renders the recorded recipe: {show}"
2607 );
2608}
2609
2610/// The issues page carries a `datalist#members` of enrolled usernames so
2611/// the assignees field completes by member id in place.
2612#[tokio::test]
2613async fn issue_forms_carry_a_members_datalist() {
2614 let dir = seed_repo(&[("README.md", "# hi\n")]);
2615 let state = build_state_at(
2616 FixtureIdentity {
2617 name: "local-user",
2618 key: Keypair::from_seed(1),
2619 },
2620 dir.path().to_owned(),
2621 );
2622 let router = ents_web::router(state);
2623 let body = get_body(&router, "/issues").await;
2624 assert!(
2625 body.contains("datalist id=\"members\""),
2626 "the assignees field has a members datalist to complete from: {body}"
2627 );
2628}
2629
2630/// A show page for an id with no ref at all is a real 404, not a 500 --
2631/// `ents_forge::Error::NotFound` keeps its status through the `Forge`
2632/// box (the box exists for variant-size hygiene only).
2633#[tokio::test]
2634async fn missing_forge_entity_is_a_404_not_a_500() {
2635 let dir = seed_repo(&[("README.md", "# hi\n")]);
2636 let state = build_state_at(
2637 FixtureIdentity {
2638 name: "local-user",
2639 key: Keypair::from_seed(1),
2640 },
2641 dir.path().to_owned(),
2642 );
2643 let router = ents_web::router(state);
2644 for path in ["/issues/nope", "/comments/nope"] {
2645 let response = router
2646 .clone()
2647 .oneshot(Request::get(path).body(Body::empty()).expect("request"))
2648 .await
2649 .expect("in-process call");
2650 assert_eq!(response.status(), StatusCode::NOT_FOUND, "GET {path}");
2651 }
2652}
2653
2654/// `GET /toolchains` surfaces a toolchain written by an older schema
2655/// (piece 1's bug: this repository's own
2656/// `refs/meta/toolchains/{rust,sccache,zig}` still carry it) through the
2657/// shared unreadable disclosure, never a 500 -- and a good toolchain
2658/// alongside it still lists and links normally.
2659#[tokio::test]
2660async fn toolchains_list_marks_a_legacy_entry_but_still_lists_a_good_one() {
2661 let refs = MemRefStore::default();
2662 let objects = ObjectStore::default();
2663 let name: gix::refs::FullName = "refs/meta/toolchains/good".try_into().expect("valid");
2664 write_meta_entity(
2665 &refs,
2666 &objects,
2667 name,
2668 &Toolchain {
2669 name: "good".to_owned(),
2670 recipe: "embedded 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n".to_owned(),
2671 },
2672 None,
2673 100,
2674 );
2675 write_legacy_toolchain(&refs, &objects, "legacy");
2676
2677 let state = build_state_with(
2678 FixtureIdentity {
2679 name: "local-user",
2680 key: Keypair::from_seed(1),
2681 },
2682 refs,
2683 objects,
2684 );
2685 let router = ents_web::router(state);
2686
2687 let response = router
2688 .oneshot(
2689 Request::get("/toolchains")
2690 .body(Body::empty())
2691 .expect("request"),
2692 )
2693 .await
2694 .expect("in-process call");
2695 assert_eq!(response.status(), StatusCode::OK);
2696 let body = response
2697 .into_body()
2698 .collect()
2699 .await
2700 .expect("body")
2701 .to_bytes();
2702 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
2703 assert!(body.contains(r#"href="/toolchains/good""#));
2704 assert!(body.contains(r#"href="/toolchains/legacy""#));
2705 assert!(
2706 body.contains("unreadable-note") && body.contains("1 unreadable"),
2707 "the legacy entry surfaces through the shared disclosure: {body}"
2708 );
2709 assert!(
2710 body.contains("refs/meta/toolchains/legacy"),
2711 "the disclosure names the failed ref"
2712 );
2713}
2714
2715/// `GET /toolchains/{name}` on a legacy-schema entry renders the marker
2716/// card (with the underlying error) rather than a 500.
2717#[tokio::test]
2718async fn toolchain_show_on_a_legacy_entry_renders_a_marker_not_a_500() {
2719 let refs = MemRefStore::default();
2720 let objects = ObjectStore::default();
2721 write_legacy_toolchain(&refs, &objects, "legacy");
2722
2723 let state = build_state_with(
2724 FixtureIdentity {
2725 name: "local-user",
2726 key: Keypair::from_seed(1),
2727 },
2728 refs,
2729 objects,
2730 );
2731 let router = ents_web::router(state);
2732
2733 let response = router
2734 .oneshot(
2735 Request::get("/toolchains/legacy")
2736 .body(Body::empty())
2737 .expect("request"),
2738 )
2739 .await
2740 .expect("in-process call");
2741 assert_eq!(response.status(), StatusCode::OK);
2742 let body = response
2743 .into_body()
2744 .collect()
2745 .await
2746 .expect("body")
2747 .to_bytes();
2748 let body = String::from_utf8(body.to_vec()).expect("utf8 html");
2749 assert!(body.contains("unreadable"));
2750 assert!(
2751 body.contains("is not a blob"),
2752 "the underlying facet-git-tree error renders verbatim: {body}"
2753 );
2754}
2755
2756/// A real, readable entity (not merely an empty ref store) exercised on
2757/// every list/show page pair `read_all`'s `state.objects()` double-lock
2758/// regression could hit: each page must complete rather than hang forever
2759/// (a non-reentrant `Mutex` self-deadlock, previously reachable whenever a
2760/// row's tree actually read back cleanly -- see the fix commit's own
2761/// message). `#[tokio::test]`'s single-threaded runtime means a real
2762/// deadlock here hangs the whole test binary rather than merely failing
2763/// it, so this is worth pinning down explicitly rather than trusting the
2764/// list/show pages' other tests to happen to seed data.
2765#[tokio::test]
2766async fn members_effects_redactions_and_toolchains_list_and_show_a_real_entity_without_hanging() {
2767 let refs = MemRefStore::default();
2768 let objects = ObjectStore::default();
2769 enroll_member(
2770 &refs,
2771 &objects,
2772 "jdc",
2773 &Keypair::from_seed(1),
2774 Provenance::AdminRegistered,
2775 100,
2776 );
2777 let effect_name: gix::refs::FullName = "refs/meta/effects/ci".try_into().expect("valid");
2778 write_meta_entity(
2779 &refs,
2780 &objects,
2781 effect_name,
2782 &Effect {
2783 name: "ci".to_owned(),
2784 trigger: "rev(refs/heads/main)".to_owned(),
2785 toolchains: vec![],
2786 run: "true".to_owned(),
2787 },
2788 None,
2789 100,
2790 );
2791 let redaction_name: gix::refs::FullName = "refs/meta/redactions/1".try_into().expect("valid");
2792 write_meta_entity(
2793 &refs,
2794 &objects,
2795 redaction_name,
2796 &Redaction::new(gix_hash::ObjectId::null(gix_hash::Kind::Sha1), "leaked"),
2797 None,
2798 100,
2799 );
2800 let toolchain_name: gix::refs::FullName =
2801 "refs/meta/toolchains/good".try_into().expect("valid");
2802 write_meta_entity(
2803 &refs,
2804 &objects,
2805 toolchain_name,
2806 &Toolchain {
2807 name: "good".to_owned(),
2808 recipe: "embedded 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n".to_owned(),
2809 },
2810 None,
2811 100,
2812 );
2813
2814 let state = build_state_with(
2815 FixtureIdentity {
2816 name: "local-user",
2817 key: Keypair::from_seed(2),
2818 },
2819 refs,
2820 objects,
2821 );
2822 let router = ents_web::router(state);
2823
2824 for path in [
2825 "/members",
2826 "/members/jdc",
2827 "/effects",
2828 "/effects/ci",
2829 "/redactions",
2830 "/redactions/1",
2831 "/toolchains",
2832 "/toolchains/good",
2833 ] {
2834 let response = router
2835 .clone()
2836 .oneshot(Request::get(path).body(Body::empty()).expect("request"))
2837 .await
2838 .expect("in-process call");
2839 assert_eq!(response.status(), StatusCode::OK, "GET {path}");
2840 }
2841}
2842
2843// ---------------------------------------------------------------------
2844// roots.web-signin: the hosted sign-in surface, driven exactly as the
2845// CLI and a browser would drive it, still with no socket anywhere.
2846// ---------------------------------------------------------------------
2847
2848/// Sign `payload` under the *login* namespace with seed `seed`'s key --
2849/// the same deterministic key `Keypair::from_seed(seed)` wraps, rebuilt
2850/// here because `Keypair::sign` deliberately signs only git's own commit
2851/// namespace.
2852fn login_sign(seed: u8, payload: &[u8]) -> String {
2853 use ssh_key::private::{Ed25519Keypair, KeypairData};
2854 use ssh_key::{HashAlg, LineEnding, PrivateKey};
2855 let pair = Ed25519Keypair::from_seed(&[seed; 32]);
2856 let key = PrivateKey::new(KeypairData::from(pair), "test").expect("well-formed");
2857 key.sign(ents_web::auth::LOGIN_NAMESPACE, HashAlg::Sha512, payload)
2858 .expect("signing is infallible")
2859 .to_pem(LineEnding::LF)
2860 .expect("renders")
2861}
2862
2863/// Percent-encode a form value (the armored signature carries newlines,
2864/// `+`, `/`, and `=`, every one of which is significant to a form body).
2865fn urlencode(value: &str) -> String {
2866 value
2867 .bytes()
2868 .map(|b| match b {
2869 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2870 char::from(b).to_string()
2871 }
2872 _ => format!("%{b:02X}"),
2873 })
2874 .collect()
2875}
2876
2877/// A sign-in-required state over `refs`/`objects`, host `ents.test` --
2878/// the hosted composition root's shape (`roots.single-node-hosted`),
2879/// minus the real repo.
2880fn build_signin_state(
2881 identity: FixtureIdentity,
2882 refs: MemRefStore,
2883 objects: ObjectStore,
2884) -> Arc<AppState<ObjectStore>> {
2885 Arc::new(
2886 AppState::new(
2887 Box::new(refs),
2888 objects,
2889 Box::new(NullEventSink),
2890 Mode::Advisory,
2891 Box::new(identity),
2892 std::env::temp_dir(),
2893 )
2894 .with_access(ents_web::state::AccessPolicy::SignInRequired(
2895 ents_web::state::Realm {
2896 host: "ents.test".to_owned(),
2897 challenges: ents_web::auth::ChallengeStore::default(),
2898 },
2899 )),
2900 )
2901}
2902
2903/// GET /login with `cookie`, returning the fresh code the page displays.
2904async fn fetch_login_code(router: &axum::Router, cookie: &str) -> String {
2905 let response = router
2906 .clone()
2907 .oneshot(
2908 Request::get("/login")
2909 .header(header::COOKIE, cookie)
2910 .body(Body::empty())
2911 .expect("request"),
2912 )
2913 .await
2914 .expect("in-process call");
2915 assert_eq!(response.status(), StatusCode::OK);
2916 let body = String::from_utf8(
2917 response
2918 .into_body()
2919 .collect()
2920 .await
2921 .expect("body")
2922 .to_bytes()
2923 .to_vec(),
2924 )
2925 .expect("utf8");
2926 let after = body
2927 .split("ents.test ")
2928 .nth(1)
2929 .expect("the page displays the login command");
2930 after.chars().take(9).collect()
2931}
2932
2933/// Complete a challenge for `code` as seed `seed`'s key, returning the
2934/// response.
2935async fn complete_challenge(
2936 router: &axum::Router,
2937 code: &str,
2938 seed: u8,
2939 host: &str,
2940) -> axum::response::Response {
2941 let challenge = router
2942 .clone()
2943 .oneshot(
2944 Request::get(format!("/login/challenge/{code}"))
2945 .body(Body::empty())
2946 .expect("request"),
2947 )
2948 .await
2949 .expect("in-process call");
2950 assert_eq!(challenge.status(), StatusCode::OK, "challenge fetch");
2951 let text = String::from_utf8(
2952 challenge
2953 .into_body()
2954 .collect()
2955 .await
2956 .expect("body")
2957 .to_bytes()
2958 .to_vec(),
2959 )
2960 .expect("utf8");
2961 let nonce = text
2962 .lines()
2963 .find_map(|line| line.strip_prefix("nonce="))
2964 .expect("a nonce line");
2965
2966 let normalized = ents_web::auth::normalize_code(code);
2967 let payload = ents_web::auth::challenge_payload(host, &normalized, nonce);
2968 let signature = login_sign(seed, payload.as_bytes());
2969 let public_key = Keypair::from_seed(seed).public_openssh();
2970 let form = format!(
2971 "public_key={}&signature={}",
2972 urlencode(&public_key),
2973 urlencode(&signature)
2974 );
2975 router
2976 .clone()
2977 .oneshot(
2978 Request::post(format!("/login/challenge/{code}"))
2979 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2980 .body(Body::from(form))
2981 .expect("request"),
2982 )
2983 .await
2984 .expect("in-process call")
2985}
2986
2987/// Rewrite `username`'s member record as revoked, directly against
2988/// `state`'s own stores -- the commit is staged via a scratch ref store
2989/// (`write_meta_entity` wants a concrete `MemRefStore`) and applied to
2990/// the live one through the trait's own CAS transaction.
2991fn revoke_member(state: &AppState<ObjectStore>, username: &str, key: &Keypair, seconds: i64) {
2992 let scratch = MemRefStore::default();
2993 let name = ents_model::namespace::member_ref(&MemberId::new(username)).expect("valid");
2994 let mut member = ents_model::Member::new(
2995 MemberId::new(username),
2996 key.public_openssh(),
2997 Provenance::AdminRegistered,
2998 );
2999 member.state = ents_model::MemberState::Revoked;
3000 let oid = write_meta_entity(
3001 &scratch,
3002 &*state.objects(),
3003 name.clone(),
3004 &member,
3005 Some(&Keypair::from_seed(9)),
3006 seconds,
3007 );
3008 state
3009 .refs
3010 .transaction(&[gix_ref_store::RefEdit {
3011 name,
3012 expected: gix_ref_store::Expected::Any,
3013 new: Some(oid),
3014 }])
3015 .expect("applies");
3016}
3017
3018#[tokio::test]
3019// @relation(roots.web-signin, scope=function, role=Verifies)
3020async fn the_login_surface_is_unrouted_under_trusted() {
3021 let state = build_state(FixtureIdentity {
3022 name: "local-user",
3023 key: Keypair::from_seed(1),
3024 });
3025 let router = ents_web::router(Arc::clone(&state));
3026 for path in ["/login", "/login/challenge/ABCD2345"] {
3027 let response = router
3028 .clone()
3029 .oneshot(Request::get(path).body(Body::empty()).expect("request"))
3030 .await
3031 .expect("in-process call");
3032 assert_eq!(response.status(), StatusCode::NOT_FOUND, "GET {path}");
3033 }
3034}
3035
3036#[tokio::test]
3037// @relation(roots.web-signin, scope=function, role=Verifies)
3038async fn the_cli_challenge_flow_signs_the_browser_session_in() {
3039 let refs = MemRefStore::default();
3040 let objects = ObjectStore::default();
3041 let joey = Keypair::from_seed(7);
3042 enroll_member(
3043 &refs,
3044 &objects,
3045 "joey",
3046 &joey,
3047 Provenance::AdminRegistered,
3048 100,
3049 );
3050 let state = build_signin_state(
3051 FixtureIdentity {
3052 name: "server",
3053 key: Keypair::from_seed(9),
3054 },
3055 refs,
3056 objects,
3057 );
3058 let router = ents_web::router(Arc::clone(&state));
3059
3060 let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/").await;
3061 let code = fetch_login_code(&router, &cookie).await;
3062
3063 let response = complete_challenge(&router, &code, 7, "ents.test").await;
3064 assert_eq!(response.status(), StatusCode::OK, "sign-in completes");
3065
3066 // The browser's next look at /login reads as signed in.
3067 let session_id = cookie
3068 .split(';')
3069 .next()
3070 .expect("segment")
3071 .split_once('=')
3072 .expect("name=value")
3073 .1
3074 .to_owned();
3075 let member = state
3076 .sessions
3077 .get(&session_id)
3078 .expect("held")
3079 .member
3080 .expect("signed in");
3081 assert_eq!(member.username, "joey");
3082
3083 // And a second post of the same code finds it consumed -- posted
3084 // directly, since the challenge fetch itself now correctly 404s.
3085 let replay = router
3086 .clone()
3087 .oneshot(
3088 Request::post(format!("/login/challenge/{code}"))
3089 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
3090 .body(Body::from("public_key=x&signature=y"))
3091 .expect("request"),
3092 )
3093 .await
3094 .expect("in-process call");
3095 assert_eq!(replay.status(), StatusCode::NOT_FOUND, "single-use");
3096}
3097
3098#[tokio::test]
3099// @relation(roots.web-signin, scope=function, role=Verifies)
3100async fn a_wrong_host_signature_or_foreign_key_is_refused() {
3101 let refs = MemRefStore::default();
3102 let objects = ObjectStore::default();
3103 let joey = Keypair::from_seed(7);
3104 enroll_member(
3105 &refs,
3106 &objects,
3107 "joey",
3108 &joey,
3109 Provenance::AdminRegistered,
3110 100,
3111 );
3112 let state = build_signin_state(
3113 FixtureIdentity {
3114 name: "server",
3115 key: Keypair::from_seed(9),
3116 },
3117 refs,
3118 objects,
3119 );
3120 let router = ents_web::router(Arc::clone(&state));
3121 let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/").await;
3122
3123 // A signature over another deployment's host does not verify here.
3124 let code = fetch_login_code(&router, &cookie).await;
3125 let response = complete_challenge(&router, &code, 7, "evil.example").await;
3126 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3127
3128 // An unenrolled key's valid signature is refused as not a member.
3129 let code = fetch_login_code(&router, &cookie).await;
3130 let response = complete_challenge(&router, &code, 3, "ents.test").await;
3131 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3132
3133 // A revoked member's key is refused the same way.
3134 revoke_member(&state, "joey", &joey, 200);
3135 let code = fetch_login_code(&router, &cookie).await;
3136 let response = complete_challenge(&router, &code, 7, "ents.test").await;
3137 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3138}
3139
3140#[tokio::test]
3141// @relation(roots.web-signin, roots.web-session, scope=function, role=Verifies)
3142async fn mutations_require_a_live_signed_in_member_and_csrf_still_gates() {
3143 let refs = MemRefStore::default();
3144 let objects = ObjectStore::default();
3145 let joey = Keypair::from_seed(7);
3146 enroll_member(
3147 &refs,
3148 &objects,
3149 "joey",
3150 &joey,
3151 Provenance::AdminRegistered,
3152 100,
3153 );
3154 let state = build_signin_state(
3155 FixtureIdentity {
3156 name: "server",
3157 key: Keypair::from_seed(9),
3158 },
3159 refs,
3160 objects,
3161 );
3162 let router = ents_web::router(Arc::clone(&state));
3163 let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/").await;
3164
3165 let post_account = |form: String, with_cookie: bool, accept_html: bool| {
3166 let mut request = Request::post("/account")
3167 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
3168 if with_cookie {
3169 request = request.header(header::COOKIE, cookie.clone());
3170 }
3171 if accept_html {
3172 request = request.header(header::ACCEPT, "text/html");
3173 }
3174 router
3175 .clone()
3176 .oneshot(request.body(Body::from(form)).expect("request"))
3177 };
3178
3179 // Anonymous: a browser-shaped POST redirects to /login, a bare one
3180 // gets 401.
3181 let form = format!("member=joey&login=j@ents.test&csrf={csrf}");
3182 let response = post_account(form.clone(), true, true).await.expect("call");
3183 assert_eq!(response.status(), StatusCode::SEE_OTHER);
3184 assert_eq!(
3185 response.headers().get(header::LOCATION).expect("location"),
3186 "/login"
3187 );
3188 let response = post_account(form.clone(), true, false).await.expect("call");
3189 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3190
3191 // Sign in, then the same POST passes the middleware -- and CSRF is
3192 // still enforced on top of it.
3193 let code = fetch_login_code(&router, &cookie).await;
3194 let signed_in = complete_challenge(&router, &code, 7, "ents.test").await;
3195 assert_eq!(signed_in.status(), StatusCode::OK);
3196 let response = post_account(form.clone(), true, true).await.expect("call");
3197 assert_eq!(response.status(), StatusCode::SEE_OTHER, "mutation lands");
3198 assert_eq!(
3199 response.headers().get(header::LOCATION).expect("location"),
3200 "/account"
3201 );
3202
3203 // The landed commit is authored by the member and committed by the
3204 // server identity -- "joey via the web"
3205 // (receive.attributed-author, roots.web-signing).
3206 {
3207 use gix_object::Find as _;
3208 let account_ref: gix::refs::FullName = ents_model::namespace::ACCOUNT_REF
3209 .try_into()
3210 .expect("valid");
3211 let tip = state
3212 .refs
3213 .get(account_ref.as_ref())
3214 .expect("readable")
3215 .expect("written");
3216 let mut buf = Vec::new();
3217 let objects = state.objects();
3218 let data = objects
3219 .try_find(&tip, &mut buf)
3220 .expect("readable")
3221 .expect("present");
3222 let commit = gix_object::CommitRef::from_bytes(data.data, tip.kind()).expect("parses");
3223 assert_eq!(
3224 commit.author().expect("author").name,
3225 "joey",
3226 "authored by the signed-in member"
3227 );
3228 assert_eq!(
3229 commit.committer().expect("committer").name,
3230 "server",
3231 "committed by the server identity"
3232 );
3233 }
3234 let bad_csrf = "member=joey&login=j@ents.test&csrf=not-the-token".to_owned();
3235 let response = post_account(bad_csrf, true, true).await.expect("call");
3236 assert_ne!(
3237 response.status(),
3238 StatusCode::SEE_OTHER,
3239 "a signed-in session still fails a wrong csrf token"
3240 );
3241
3242 // Revoke joey mid-session: the next mutation is refused and the
3243 // session is signed out, not just bounced.
3244 revoke_member(&state, "joey", &joey, 300);
3245 let response = post_account(form, true, true).await.expect("call");
3246 assert_eq!(response.status(), StatusCode::SEE_OTHER);
3247 assert_eq!(
3248 response.headers().get(header::LOCATION).expect("location"),
3249 "/login"
3250 );
3251 let session_id = cookie
3252 .split(';')
3253 .next()
3254 .expect("segment")
3255 .split_once('=')
3256 .expect("name=value")
3257 .1;
3258 assert!(
3259 state
3260 .sessions
3261 .get(session_id)
3262 .expect("held")
3263 .member
3264 .is_none(),
3265 "a revoked member is signed out, not left holding a dead session"
3266 );
3267}
3268
3269/// `GET /reviews` (`crate::pages::reviews`): a withdrawn review stays in
3270/// `refs/meta/reviews/*`'s own history (`model.review`, append-only) but
3271/// must not render in this aggregate listing, while an ordinary active
3272/// review of the same target still does -- the filter this page's own
3273/// `list` applies on `ents_forge::review::ReviewState::Withdrawn`.
3274#[tokio::test]
3275async fn reviews_list_hides_a_withdrawn_review_but_keeps_an_active_one() {
3276 let refs = MemRefStore::default();
3277 let objects = ObjectStore::default();
3278 let target = "0123456789abcdef0123456789abcdef01234567";
3279 let reviewed = gix_hash::ObjectId::from_hex(target.as_bytes()).expect("valid hex");
3280
3281 let active_ref =
3282 ents_model::namespace::review_ref(target, &MemberId::new("alice")).expect("valid");
3283 write_meta_entity(
3284 &refs,
3285 &objects,
3286 active_ref,
3287 &ents_forge::review::Review::new(
3288 reviewed,
3289 ents_forge::review::Verdict::Approve,
3290 "looks good",
3291 ),
3292 None,
3293 100,
3294 );
3295
3296 let withdrawn_ref =
3297 ents_model::namespace::review_ref(target, &MemberId::new("bob")).expect("valid");
3298 write_meta_entity(
3299 &refs,
3300 &objects,
3301 withdrawn_ref,
3302 &ents_forge::review::Review::new(
3303 reviewed,
3304 ents_forge::review::Verdict::RequestChanges,
3305 "please fix this",
3306 )
3307 .withdrawn(),
3308 None,
3309 100,
3310 );
3311
3312 let state = build_state_with(
3313 FixtureIdentity {
3314 name: "local-user",
3315 key: Keypair::from_seed(1),
3316 },
3317 refs,
3318 objects,
3319 );
3320 let router = ents_web::router(state);
3321
3322 let body = get_body(&router, "/reviews").await;
3323 assert!(body.contains("alice"), "active review still lists: {body}");
3324 assert!(!body.contains("bob"), "withdrawn review must not render: {body}");
3325}
3326
3327/// `GET /reviews/{target}/{member}` (`crate::pages::reviews::show`): a
3328/// review started through the commit page's own form (`POST
3329/// /commit/{oid}/review`) gets its own detail page -- the verdict chip, an
3330/// `active` state badge, the rendered body, and (since the viewing
3331/// identity is the review's own author) a withdraw control -- and the
3332/// Reviews split's own sidebar (`reviews_sidebar`) links to it, newest
3333/// first, beside `GET /reviews`'s own aggregate listing.
3334#[tokio::test]
3335// @relation(model.review, lens.parity, scope=function, role=Verifies)
3336async fn review_detail_page_renders_for_its_own_author_with_a_withdraw_control() {
3337 let dir = seed_repo(&[("src/main.rs", "fn main() {}\n")]);
3338 let oid = head_oid(dir.path());
3339 let state = build_state_at(
3340 FixtureIdentity {
3341 name: "reviewer",
3342 key: Keypair::from_seed(1),
3343 },
3344 dir.path().to_owned(),
3345 );
3346 let router = ents_web::router(state.clone());
3347
3348 let (cookie, csrf) = session_cookie_and_csrf(&router, &state, &format!("/commit/{oid}")).await;
3349 let started = router
3350 .clone()
3351 .oneshot(
3352 Request::post(format!("/commit/{oid}/review"))
3353 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
3354 .header(header::COOKIE, cookie.clone())
3355 .body(Body::from(format!(
3356 "verdict=approve&body=looks+good+to+me&csrf={csrf}"
3357 )))
3358 .expect("request"),
3359 )
3360 .await
3361 .expect("in-process call");
3362 assert!(started.status().is_redirection(), "{:?}", started.status());
3363
3364 let commit_page = get_body(&router, &format!("/commit/{oid}")).await;
3365 let review_id = commit_page
3366 .split_once("/reviews/")
3367 .and_then(|(_, rest)| rest.split_once("/comment"))
3368 .map(|(id, _)| id)
3369 .expect("a review comment form links in")
3370 .to_owned();
3371
3372 // The sidebar/list both link to the review's own detail page.
3373 let list = get_body(&router, "/reviews").await;
3374 assert!(
3375 list.contains(&format!("href=\"/reviews/{review_id}\"")),
3376 "the sidebar links the active review's own detail page: {list}"
3377 );
3378
3379 let detail = get_body(&router, &format!("/reviews/{review_id}")).await;
3380 assert!(
3381 detail.contains("class=\"verdict verdict-approve\""),
3382 "the verdict chip renders: {detail}"
3383 );
3384 assert!(
3385 detail.contains("looks good to me"),
3386 "the review body renders as its own doc-body: {detail}"
3387 );
3388 assert!(
3389 detail.contains(">active<"),
3390 "the state badge names the review active: {detail}"
3391 );
3392 assert!(
3393 detail.contains(&format!("action=\"/reviews/{review_id}/withdraw\"")),
3394 "the review's own author sees a withdraw control: {detail}"
3395 );
3396
3397 // Withdrawing redirects back to the same detail page, which still
3398 // renders -- but now with the withdrawn indicator instead of the
3399 // button -- while the sidebar and the aggregate list both drop it.
3400 let (cookie, csrf) = session_cookie_and_csrf(&router, &state, &detail_path(&review_id)).await;
3401 let withdrawn = router
3402 .clone()
3403 .oneshot(
3404 Request::post(format!("/reviews/{review_id}/withdraw"))
3405 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
3406 .header(header::COOKIE, cookie)
3407 .body(Body::from(format!("csrf={csrf}")))
3408 .expect("request"),
3409 )
3410 .await
3411 .expect("in-process call");
3412 assert!(
3413 withdrawn.status().is_redirection(),
3414 "{:?}",
3415 withdrawn.status()
3416 );
3417
3418 let detail_after = get_body(&router, &format!("/reviews/{review_id}")).await;
3419 assert!(
3420 detail_after.contains("withdrawn"),
3421 "the withdrawn review's own page states so plainly: {detail_after}"
3422 );
3423 assert!(
3424 !detail_after.contains(&format!("action=\"/reviews/{review_id}/withdraw\"")),
3425 "a withdrawn review no longer offers the withdraw control: {detail_after}"
3426 );
3427
3428 let list_after = get_body(&router, "/reviews").await;
3429 assert!(
3430 !list_after.contains(&format!("href=\"/reviews/{review_id}\"")),
3431 "the withdrawn review drops out of the aggregate list: {list_after}"
3432 );
3433}
3434
3435/// `format!("/reviews/{{review_id}}")`, spelled once so
3436/// [`review_detail_page_renders_for_its_own_author_with_a_withdraw_control`]
3437/// can reuse the same detail path both to fetch a fresh CSRF token and to
3438/// `POST` the withdraw form against it.
3439fn detail_path(review_id: &str) -> String {
3440 format!("/reviews/{review_id}")
3441}
3442
3443/// `crate::pages::reviews::show`'s withdraw control only ever renders for
3444/// the review's *own* author -- a second identity viewing the same
3445/// still-active review sees the metadata card and the thread, but no
3446/// withdraw form at all, exactly as `commits::reviews_section` never lets
3447/// one member's page render a button that would fail
3448/// `ents_forge::review::withdraw`'s own author check.
3449#[tokio::test]
3450async fn review_detail_page_hides_the_withdraw_control_from_a_non_author() {
3451 let refs = MemRefStore::default();
3452 let objects = ObjectStore::default();
3453 let target = "0123456789abcdef0123456789abcdef01234567";
3454 let reviewed = gix_hash::ObjectId::from_hex(target.as_bytes()).expect("valid hex");
3455
3456 let review_ref =
3457 ents_model::namespace::review_ref(target, &MemberId::new("carol")).expect("valid");
3458 write_meta_entity(
3459 &refs,
3460 &objects,
3461 review_ref,
3462 &ents_forge::review::Review::new(
3463 reviewed,
3464 ents_forge::review::Verdict::Approve,
3465 "review body from carol",
3466 ),
3467 None,
3468 100,
3469 );
3470
3471 let state = build_state_with(
3472 FixtureIdentity {
3473 name: "onlooker",
3474 key: Keypair::from_seed(2),
3475 },
3476 refs,
3477 objects,
3478 );
3479 let router = ents_web::router(state);
3480
3481 let detail = get_body(&router, &format!("/reviews/{target}/carol")).await;
3482 assert!(
3483 detail.contains("review body from carol"),
3484 "the review still renders for a non-author viewer: {detail}"
3485 );
3486 assert!(
3487 !detail.contains("/withdraw\""),
3488 "a non-author viewer sees no withdraw control: {detail}"
3489 );
3490}
3491
3492/// `POST /reviews/{target}/{member}/withdraw` is a state-changing route
3493/// gated the same way every other mutation in this crate is
3494/// (`roots.web-session`): no CSRF field at all is rejected outright,
3495/// regardless of whether the named review even exists.
3496#[tokio::test]
3497async fn review_withdraw_is_rejected_without_a_valid_csrf_token() {
3498 let state = build_state(FixtureIdentity {
3499 name: "local-user",
3500 key: Keypair::from_seed(1),
3501 });
3502 let router = ents_web::router(Arc::clone(&state));
3503
3504 let no_csrf = router
3505 .clone()
3506 .oneshot(
3507 Request::post("/reviews/0123456789abcdef0123456789abcdef01234567/carol/withdraw")
3508 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
3509 .body(Body::empty())
3510 .expect("request"),
3511 )
3512 .await
3513 .expect("in-process call");
3514 assert!(
3515 !no_csrf.status().is_success() && !no_csrf.status().is_redirection(),
3516 "a POST with no csrf field must not withdraw a review"
3517 );
3518
3519 let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/reviews").await;
3520 let wrong = router
3521 .oneshot(
3522 Request::post("/reviews/0123456789abcdef0123456789abcdef01234567/carol/withdraw")
3523 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
3524 .header(header::COOKIE, cookie)
3525 .body(Body::from("csrf=not-the-token"))
3526 .expect("request"),
3527 )
3528 .await
3529 .expect("in-process call");
3530 assert_eq!(wrong.status(), StatusCode::BAD_REQUEST);
3531}