//! End-to-end: drive the router with a MockFetcher (offline). Asserts the //! themed render, the whitelist 404, the traversal refusal, and the //! fail-loud broken-reference block. use axum::body::to_bytes; use axum::http::{Request, StatusCode}; use docsite::cache::Cache; use docsite::config::Registry; use docsite::gitea::{Fetcher, GiteaError, MockFetcher}; use docsite::server::{router, AppState}; use std::sync::Arc; use tower::ServiceExt; // oneshot fn reg() -> Registry { toml::from_str( r#" bind="0.0.0.0:0" gitea="http://gitea.test" [[project]] slug="aura" repo="Brummel/aura" branch="main" docs="docs" [[project.section]] title="Design" pages=["design/INDEX.md"] "#, ).unwrap() } fn state() -> AppState { let fetcher = MockFetcher::new("a517899").with( "Brummel/aura", "docs/design/INDEX.md", "# Ledger\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n```rust\nfn x() {}\n```\n", ); AppState { reg: Arc::new(reg()), fetcher: Arc::new(fetcher), cache: Arc::new(Cache::new()), } } async fn get(app: axum::Router, uri: &str) -> (StatusCode, String) { let resp = app.oneshot(Request::builder().uri(uri).body(axum::body::Body::empty()).unwrap()) .await.unwrap(); let status = resp.status(); let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); (status, String::from_utf8_lossy(&bytes).to_string()) } #[tokio::test] async fn renders_listed_page_with_theme_and_nav() { let (status, body) = get(router(state()), "/aura/design/INDEX").await; assert_eq!(status, StatusCode::OK); assert!(body.starts_with(""), "no themed shell"); assert!(body.contains("href=\"/assets/theme.css\""), "no stylesheet"); assert!(body.contains(""), "no nav from registry"); assert!(body.contains(""), "table not rendered"); assert!(body.contains(" visible error block let mut s = state(); let r: Registry = toml::from_str( "bind=\"x\"\ngitea=\"y\"\n[[project]]\nslug=\"aura\"\nrepo=\"Brummel/aura\"\nbranch=\"main\"\ndocs=\"docs\"\n[[project.section]]\ntitle=\"D\"\npages=[\"gone.md\"]\n", ).unwrap(); s.reg = Arc::new(r); let (status, body) = get(router(s), "/aura/gone").await; assert_eq!(status, StatusCode::OK, "broken ref is content, not a server fault"); assert!(body.contains("docsite-error"), "no fail-loud error block: {body}"); assert!(body.contains("broken reference"), "error block text missing"); } /// A Fetcher that is always unreachable — every call is a transport error. /// Lets the E2E layer reach the 502 arm that MockFetcher (which only yields /// Ok / NotFound) cannot exercise. struct UnreachableFetcher; impl Fetcher for UnreachableFetcher { fn branch_sha(&self, _repo: &str, _branch: &str) -> Result { Err(GiteaError::Transport("connection refused".into())) } fn fetch_raw(&self, _repo: &str, _branch: &str, _path: &str) -> Result { Err(GiteaError::Transport("connection refused".into())) } } /// Invariant 3's broken-ref-vs-unreachable distinction, at the HTTP layer: an /// *unreachable* Gitea is a server-side fault (502 Bad Gateway), NOT the 200 /// fail-loud content block that a renamed/missing source produces. Collapsing /// the two — e.g. rendering a 200 error block on a transport failure — would let /// a transient outage masquerade as a permanent broken reference. The page is /// whitelisted so `resolve` passes and the transport arm is what's exercised. /// This is the twin of `broken_reference_shows_failloud_block` (the 200 arm). #[tokio::test] async fn transport_failure_is_502_not_failloud_block() { let st = AppState { reg: Arc::new(reg()), fetcher: Arc::new(UnreachableFetcher), cache: Arc::new(Cache::new()), }; let (status, body) = get(router(st), "/aura/design/INDEX").await; assert_eq!(status, StatusCode::BAD_GATEWAY, "unreachable Gitea must be 502, not 200 content"); assert!( !body.contains("docsite-error"), "a transport failure must not render the fail-loud broken-ref block: {body}" ); } /// The themed page links its stylesheet at `/assets/theme.css`; this pins that /// the route actually *serves* the embedded stylesheet — 200, a `text/css` /// content type, and the real CSS body — so the self-contained binary's styling /// cannot silently 404. `renders_listed_page_with_theme_and_nav` only asserts the /// href string is present in the shell, never that the route resolves. #[tokio::test] async fn theme_css_route_serves_embedded_stylesheet() { let resp = router(state()) .oneshot( Request::builder() .uri("/assets/theme.css") .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let ctype = resp .headers() .get(axum::http::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert_eq!(ctype, "text/css", "stylesheet must be served as text/css"); let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); let body = String::from_utf8_lossy(&bytes); assert!(body.contains("--bg: #16161a"), "served body must be the embedded stylesheet"); } /// Whitelist publishing (invariant 5) at the index level: the root index /// enumerates exactly the registry's projects as navigable links — a project the /// registry does not list never appears, and each listed project links to its own /// slug root. Two projects pin that the list is built from the registry (not /// hardcoded to one), and the `
  • ` count pins *exactly* those, no more. #[tokio::test] async fn index_lists_exactly_the_registry_projects_as_links() { let r: Registry = toml::from_str( r#" bind="x" gitea="y" [[project]] slug="aura" repo="Brummel/aura" branch="main" docs="docs" [[project]] slug="data-server" repo="Brummel/data-server" branch="main" docs="docs" "#, ).unwrap(); let st = AppState { reg: Arc::new(r), fetcher: Arc::new(MockFetcher::new("a517899")), cache: Arc::new(Cache::new()), }; let (status, body) = get(router(st), "/").await; assert_eq!(status, StatusCode::OK); assert!(body.contains("aura"), "aura missing from index: {body}"); assert!( body.contains("data-server"), "data-server missing from index: {body}" ); assert_eq!( body.matches("
  • ").count(), 2, "index must list exactly the registry's projects, no more: {body}" ); } /// A Fetcher that counts `fetch_raw` calls, wrapping a MockFetcher. Lets the /// E2E layer prove the cache-before-fetch data-flow. struct CountingFetcher { inner: MockFetcher, fetches: Arc, } impl Fetcher for CountingFetcher { fn branch_sha(&self, repo: &str, branch: &str) -> Result { self.inner.branch_sha(repo, branch) } fn fetch_raw(&self, repo: &str, branch: &str, path: &str) -> Result { self.fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); self.inner.fetch_raw(repo, branch, path) } } /// Single source of truth + the revision-keyed cache (invariant 2) at the HTTP /// layer: two requests for the same page at an unchanged branch SHA must hit the /// source exactly ONCE — the second serves from cache without re-fetching. The /// pre-audit handler fetched before checking the cache, so a cache hit still /// paid the Gitea fetch; this pins the corrected hit -> serve, miss -> fetch flow. #[tokio::test] async fn second_request_serves_from_cache_without_refetching() { let fetches = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let fetcher = CountingFetcher { inner: MockFetcher::new("a517899") .with("Brummel/aura", "docs/design/INDEX.md", "# Ledger\n"), fetches: fetches.clone(), }; let app = router(AppState { reg: Arc::new(reg()), fetcher: Arc::new(fetcher), cache: Arc::new(Cache::new()), }); let (s1, _) = get(app.clone(), "/aura/design/INDEX").await; let (s2, _) = get(app, "/aura/design/INDEX").await; assert_eq!(s1, StatusCode::OK); assert_eq!(s2, StatusCode::OK); assert_eq!( fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "the second request must serve from cache, not re-fetch the source", ); }