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
This commit is contained in:
+193
@@ -0,0 +1,193 @@
|
||||
//! 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}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Gated smoke test against the real Gitea. Skips cleanly when Gitea is
|
||||
//! unreachable (the project's skip-on-no-dependency convention) so it never
|
||||
//! fails on an offline machine.
|
||||
|
||||
use docsite::gitea::{Fetcher, GiteaClient};
|
||||
|
||||
#[test]
|
||||
fn aura_glossary_is_fetchable_from_live_gitea() {
|
||||
let client = GiteaClient::new("http://192.168.178.103:3000");
|
||||
match client.fetch_raw("Brummel/aura", "main", "docs/glossary.md") {
|
||||
Ok(body) => assert!(!body.is_empty(), "live glossary should be non-empty"),
|
||||
Err(e) => {
|
||||
eprintln!("skip: live Gitea unreachable or auth-gated: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user