crates/cli/ents-web/tests/router.rs
router.rshistorycomment 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 | |
| 10 | use std::sync::Arc; |
| 11 | |
| 12 | use axum::body::Body; |
| 13 | use axum::http::{Request, StatusCode, header}; |
| 14 | use ents_kiln::Toolchain; |
| 15 | use ents_model::{Account, Effect, MemberId, Provenance, Redaction, ResultRecord, Status}; |
| 16 | use ents_receive::{Mode, NullEventSink}; |
| 17 | use ents_testutil::{ |
| 18 | CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, record_result, write_commit, |
| 19 | write_meta_entity, |
| 20 | }; |
| 21 | use ents_web::identity::SigningIdentity; |
| 22 | use ents_web::state::AppState; |
| 23 | use gix::bstr::ByteSlice as _; |
| 24 | use gix_object::tree::{Entry, EntryKind}; |
| 25 | use gix_object::{Kind, Tree, Write as _}; |
| 26 | use http_body_util::BodyExt as _; |
| 27 | use 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. |
| 32 | struct FixtureIdentity { |
| 33 | name: &'static str, |
| 34 | key: Keypair, |
| 35 | } |
| 36 | |
| 37 | impl 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 | |
| 58 | fn 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. |
| 74 | fn 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. |
| 92 | fn 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`). |
| 118 | fn 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. |
| 160 | fn 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. |
| 196 | fn 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. |
| 213 | fn 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. |
| 240 | async 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. |
| 260 | async 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. |
| 300 | async 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) |
| 347 | async 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] |
| 370 | async 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) |
| 412 | async 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] |
| 447 | async 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] |
| 509 | async 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] |
| 563 | async 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) |
| 596 | async 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) |
| 643 | async 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) |
| 743 | async 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) |
| 807 | async 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] |
| 887 | async 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] |
| 923 | async 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] |
| 953 | async 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] |
| 1011 | async 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 < 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] |
| 1047 | async 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] |
| 1085 | async 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] |
| 1137 | async 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] |
| 1176 | async 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] |
| 1222 | async 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] |
| 1272 | async 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] |
| 1329 | async 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] |
| 1364 | async 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] |
| 1420 | async 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) |
| 1479 | async 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) |
| 1553 | async 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] |
| 1594 | async 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] |
| 1641 | async 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] |
| 1679 | async 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] |
| 1716 | async 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] |
| 1755 | async 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] |
| 1793 | async 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] |
| 1828 | async 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] |
| 1857 | async 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] |
| 1892 | async 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] |
| 1931 | async 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] |
| 1972 | async 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) |
| 2067 | async 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] |
| 2151 | async 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] |
| 2221 | async 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. |
| 2242 | async 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) |
| 2292 | async 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) |
| 2366 | async 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] |
| 2407 | async 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] |
| 2441 | async 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] |
| 2476 | async 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] |
| 2525 | async 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] |
| 2570 | async 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] |
| 2613 | async 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] |
| 2634 | async 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] |
| 2660 | async 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] |
| 2718 | async 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] |
| 2766 | async 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. |
| 2852 | fn 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). |
| 2865 | fn 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. |
| 2880 | fn 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. |
| 2904 | async 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. |
| 2935 | async 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. |
| 2991 | fn 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) |
| 3020 | async 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) |
| 3038 | async 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) |
| 3100 | async 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) |
| 3142 | async 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] |
| 3275 | async 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) |
| 3336 | async 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. |
| 3439 | fn 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] |
| 3450 | async 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] |
| 3497 | async 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 | } |