cb82987c33
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
194 lines
7.1 KiB
Rust
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}"
|
|
);
|
|
}
|