Files
DocSite/tests/e2e.rs
T
Brummel cb82987c33 feat: walking skeleton — live-render aura + data-server docs from Gitea
The first runnable DocSite server (plan docs/plans/0001-walking-skeleton.md):
`docsite serve` renders the published docs of aura and data-server live from
their Gitea main revisions, dark-themed, navigated from the DocSite-owned
whitelist registry; `docsite check` validates every nav reference against the
live sources with an exit code. Built across nine TDD tasks — config (registry
whitelist + traversal guard), gitea read-only client (Fetcher trait, injected
for offline tests), render (comrak GFM + syntect), theme (embedded dark CSS +
shell), revision-keyed cache, reference validator, axum server, main wiring.

All five domain invariants land: read-only Gitea access via the Fetcher trait;
single source of truth via the (repo,path,sha) cache; reference integrity
detected (check + a fail-loud render-time error block) never enforced;
whitelist publishing with path-traversal refusal; the closed directive
vocabulary's no-op expansion seam reserved in render.

Verification (run by the orchestrator): cargo build green; cargo test green —
40 tests (32 lib unit + 7 e2e via an injected MockFetcher + 1 gated live smoke
that reached the real Gitea this run); cargo clippy --all-targets -D warnings
clean.

Held quality findings (plan-prescribed / plan-deferred, not defects; tracked
as follow-up issues): nav_html interpolates the registry slug/page into the
href unescaped (operator-trusted registry — latent, not a live vector);
GiteaClient's 404->NotFound status mapping (the invariant-3 broken-reference
signal) is covered only by the gated live smoke's happy path, not a unit test.

Resolved API deviations from the plan text (newer crate versions): reqwest
0.13 renamed feature rustls-tls -> rustls; comrak 0.52 deprecated `Plugins`
(used `comrak::options::Plugins`); axum 0.8 capture syntax `/{slug}/{*page}`.

refs #1
2026-06-28 16:43:20 +02:00

194 lines
7.1 KiB
Rust

//! 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("<!doctype html>"), "no themed shell");
assert!(body.contains("href=\"/assets/theme.css\""), "no stylesheet");
assert!(body.contains("<a href=\"/aura/design/INDEX\">"), "no nav from registry");
assert!(body.contains("<table>"), "table not rendered");
assert!(body.contains("<pre"), "code fence not rendered");
}
#[tokio::test]
async fn unlisted_page_is_404() {
// glossary is not whitelisted in any section
let (status, _) = get(router(state()), "/aura/glossary").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn traversal_is_refused() {
let (status, _) = get(router(state()), "/aura/../secret").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn broken_reference_shows_failloud_block() {
// a registry listing a page the fetcher does not have -> 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<String, GiteaError> {
Err(GiteaError::Transport("connection refused".into()))
}
fn fetch_raw(&self, _repo: &str, _branch: &str, _path: &str) -> Result<String, GiteaError> {
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 `<li>` 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("<a href=\"/aura/\">aura</a>"), "aura missing from index: {body}");
assert!(
body.contains("<a href=\"/data-server/\">data-server</a>"),
"data-server missing from index: {body}"
);
assert_eq!(
body.matches("<li>").count(),
2,
"index must list exactly the registry's projects, no more: {body}"
);
}