1 //! Review #3 UI/build regressions (f0rge #83–#96): indexes, footer,2 //! 404, CRLF gutter, encoded paths, redacted CI logs, absolute social3 //! and feed URLs, dir headings.4 5 use std::io::{BufRead, BufReader, Read, Write};6 use std::net::TcpStream;7 use std::path::{Path, PathBuf};8 use std::process::{Child, Command, Stdio};9 use std::sync::atomic::{AtomicU64, Ordering};10 use std::time::Duration;11 12 static SEQ: AtomicU64 = AtomicU64::new(0);13 14 fn fresh(prefix: &str) -> PathBuf {15 let dir = std::env::temp_dir().join(format!(16 "hu13-review3-{prefix}-{}-{}",17 std::process::id(),18 std::time::SystemTime::now()19 .duration_since(std::time::UNIX_EPOCH)20 .map(|d| d.as_nanos())21 .unwrap_or(0)22 .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))23 ));24 std::fs::create_dir_all(&dir).unwrap();25 dir26 }27 28 fn git(repo: &Path, args: &[&str]) {29 let st = Command::new("git")30 .arg("-C")31 .arg(repo)32 .args(args)33 .status()34 .expect("spawn git");35 assert!(st.success(), "git {args:?} failed");36 }37 38 fn write(repo: &Path, rel: &str, body: &str) {39 let dest = repo.join(rel);40 std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();41 std::fs::write(dest, body).unwrap();42 }43 44 const ISSUE: &str = "11111111-1111-4111-8111-111111111111";45 const PR: &str = "44444444-4444-4444-8444-444444444444";46 47 fn fixture() -> PathBuf {48 let repo = fresh("repo");49 git(&repo, &["init", "-b", "main"]);50 git(&repo, &["config", "user.name", "hu13-test"]);51 git(&repo, &["config", "user.email", "[email protected]"]);52 git(&repo, &["config", "commit.gpgsign", "false"]);53 write(&repo, "README.md", "# review fixture\n");54 write(&repo, "a#b.txt", "hash name\n");55 write(&repo, "q?x.txt", "query name\n");56 write(&repo, "src/hi.txt", "subdir\n");57 write(&repo, "crlf.txt", "a\r\nb\r\n");58 write(59 &repo,60 &format!(".forge/issues/{ISSUE}/issue.md"),61 &format!(62 "---\nid: {ISSUE}\ntitle: Closed issue\nstate: closed\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"63 ),64 );65 write(66 &repo,67 &format!(".forge/prs/{PR}/pr.md"),68 &format!(69 "---\nid: {PR}\ntitle: Closed pull\nstate: closed\nbase: main\nhead: feature/topic\ncreated: 2026-09-10T03:30:00Z\ndraft: false\nissues: []\nci: pass\n---\nbody\n"70 ),71 );72 write(73 &repo,74 ".forge/ci/runs/2026-09-10T00-00-00Z/summary.md",75 "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n - name: 00_test.sh\n exit: 0\n duration_ms: 3\n---\nsummary body\n",76 );77 write(78 &repo,79 ".forge/ci/runs/2026-09-10T00-00-00Z/steps/00_test.sh.log",80 &format!("Checking hu13 at {}\nfinished\n", repo.display()),81 );82 git(&repo, &["add", "-A"]);83 git(&repo, &["commit", "-m", "one"]);84 repo85 }86 87 fn build(repo: &Path, out: &Path) -> std::process::Output {88 Command::new(env!("CARGO_BIN_EXE_hu13"))89 .arg("build")90 .arg(repo)91 .arg("-o")92 .arg(out)93 .arg("--public-origin")94 .arg("https://hu13.example")95 .output()96 .expect("spawn hu13 build")97 }98 99 fn read(path: &Path) -> String {100 std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))101 }102 103 #[test]104 fn review3_build_regressions() {105 let repo = fixture();106 let out = fresh("out");107 let res = build(&repo, &out);108 assert!(109 res.status.success(),110 "build: {}",111 String::from_utf8_lossy(&res.stderr)112 );113 114 let index = read(&out.join("index.html"));115 assert!(116 index.contains("href=\"/files/a%23b.txt.html\""),117 "hash filename is percent-encoded in links"118 );119 assert!(120 index.contains("href=\"/files/q%3Fx.txt.html\""),121 "query filename is percent-encoded in links"122 );123 assert!(!index.contains("href=\"/files/a#b.txt.html\""));124 assert!(125 index.contains("content=\"https://hu13.example/og.png\""),126 "og:image is absolute with --public-origin"127 );128 assert!(129 index.contains("href=\"/new-issue.html\">submit</a>"),130 "footer submit points at the form, not POST-only /inbox/issue"131 );132 133 let hash_page = read(&out.join("files/a#b.txt.html"));134 assert!(hash_page.contains("href=\"/raw/a%23b.txt\""));135 let crlf = read(&out.join("files/crlf.txt.html"));136 assert!(crlf.contains("id=\"L2\""), "two lines survive");137 assert!(138 !crlf.contains("id=\"L3\""),139 "no phantom L404 after trailing CRLF"140 );141 assert!(142 !crlf.contains(" ") && !crlf.contains('\r'),143 "CR stripped"144 );145 146 let dir_page = read(&out.join("files/src.html"));147 assert!(dir_page.contains("<h1>src/</h1>"));148 assert!(!dir_page.contains("dir: src"), "debug heading removed");149 150 let issues = read(&out.join("issues/index.html"));151 let pulls = read(&out.join("pulls/index.html"));152 for page in [&issues, &pulls] {153 assert!(!page.contains("title=\"<"), "no HTML in title attributes");154 assert!(155 !page.contains("</span>\">"),156 "no leaked span attribute text"157 );158 assert!(159 !page.contains("</time>\">"),160 "no leaked time attribute text"161 );162 }163 assert!(issues.contains("title=\"closed\""), "plain state title");164 assert!(pulls.contains("class=\"lane\""), "branch lanes are tokens");165 166 let ci = read(&out.join("ci/index.html"));167 assert!(168 !ci.contains(&repo.display().to_string()),169 "builder path redacted from CI logs"170 );171 assert!(172 ci.contains("<repo>") || ci.contains("<repo>"),173 "redaction marker"174 );175 176 let feed = read(&out.join("feed.atom"));177 assert!(178 feed.contains("<link rel=\"self\" href=\"https://hu13.example/feed.atom\"/>"),179 "feed self link absolute"180 );181 assert!(182 feed.contains("href=\"https://hu13.example/commit/"),183 "entry links absolute"184 );185 186 let not_found = read(&out.join("404.html"));187 assert!(not_found.contains("<h1>404</h1>"));188 assert!(not_found.contains("site-footer"), "branded 404 has footer");189 assert!(out.join("_routes.json").is_file(), "Pages routes emitted");190 let routes = read(&out.join("_routes.json"));191 assert!(routes.contains("\"/inbox/*\""));192 }193 194 struct Server {195 child: Child,196 addr: String,197 }198 199 impl Drop for Server {200 fn drop(&mut self) {201 self.child.kill().ok();202 self.child.wait().ok();203 }204 }205 206 fn start(www: &Path, inbox: &Path) -> Server {207 let mut child = Command::new(env!("CARGO_BIN_EXE_hu13"))208 .arg("serve")209 .arg("--www")210 .arg(www)211 .arg("--inbox-dir")212 .arg(inbox)213 .arg("--listen")214 .arg("127.0.0.1:0")215 .stdout(Stdio::piped())216 .stderr(Stdio::null())217 .spawn()218 .expect("spawn serve");219 let stdout = child.stdout.take().expect("stdout");220 let mut addr = String::new();221 for line in BufReader::new(stdout).lines().take(50).flatten() {222 if let Some(rest) = line.split("listening on ").nth(1) {223 addr = rest.trim().to_string();224 break;225 }226 }227 assert!(!addr.is_empty(), "serve printed no address");228 Server { child, addr }229 }230 231 fn get(addr: &str, path: &str) -> String {232 let mut s = TcpStream::connect(addr).expect("connect");233 s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();234 write!(235 s,236 "GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"237 )238 .unwrap();239 let mut out = Vec::new();240 s.read_to_end(&mut out).unwrap();241 String::from_utf8_lossy(&out).into_owned()242 }243 244 #[test]245 fn serve_uses_branded_404_and_streams_encoded_paths() {246 let repo = fixture();247 let out = fresh("serve-out");248 let res = build(&repo, &out);249 assert!(res.status.success());250 let inbox = fresh("inbox");251 let srv = start(&out, &inbox);252 std::thread::sleep(Duration::from_millis(100));253 254 let missing = get(&srv.addr, "/definitely-not-here");255 assert!(missing.starts_with("HTTP/1.1 404"), "{missing}");256 assert!(missing.contains("<h1>404</h1>"));257 assert!(missing.contains("class=\"site-footer\""), "same chrome");258 259 let encoded = get(&srv.addr, "/files/a%23b.txt.html");260 assert!(encoded.starts_with("HTTP/1.1 200"), "{encoded}");261 let raw = get(&srv.addr, "/raw/a%23b.txt");262 assert!(raw.starts_with("HTTP/1.1 200"), "{raw}");263 assert!(raw.contains("Content-Disposition: attachment"));264 }