pub mod dashboard; pub mod issue; pub mod public; pub mod rate; pub mod session; pub mod timing; pub mod users; use std::fmt; use std::sync::{LazyLock, Mutex}; use std::time::SystemTime; use askama::Template; use async_trait::async_trait; use axum::extract::Request; use axum::http::{HeaderValue, StatusCode, header}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use jiff::Timestamp; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use self::session::Viewer; use axum_login::tower_sessions::Session; #[async_trait] pub trait JobRunner: Send + Sync { async fn start(&self, unit: &str) -> Result<(), String>; async fn status(&self, unit: &str) -> Result; async fn log(&self, unit: &str, lines: usize) -> Result; } #[derive(Debug, Clone, Default)] pub struct UnitStatus { pub active_state: String, pub sub_state: String, pub result: String, pub exit_status: Option, /// `ExecMainStartTimestamp`, as systemd prints it; empty when never run. pub started: Option, /// `ExecMainExitTimestamp`, as systemd prints it. pub exited: Option, } impl UnitStatus { /// The unit ran and stopped with a failure result (`exit-code`, `failed`, /// `signal`, `timeout`, …); a never-started unit reports `success`. pub fn exited_unsuccessfully(&self) -> bool { matches!(self.active_state.as_str(), "inactive" | "failed") && !self.result.is_empty() && self.result != "success" } } #[derive(Debug, Default)] pub struct DisabledRunner; #[async_trait] impl JobRunner for DisabledRunner { async fn start(&self, _unit: &str) -> Result<(), String> { Err("jobs are disabled".into()) } async fn status(&self, _unit: &str) -> Result { Err("jobs are disabled".into()) } async fn log(&self, _unit: &str, _lines: usize) -> Result { Err("jobs are disabled".into()) } } /// In-memory runner for router tests: records every call and answers with /// scripted results (`start` succeeds, `status` is inactive/success and `log` /// is empty unless told otherwise). #[derive(Debug, Default)] pub struct MockRunner { calls: Mutex>, start_error: Mutex>, statuses: Mutex>, log_text: Mutex, } impl MockRunner { pub fn calls(&self) -> Vec { self.calls.lock().expect("mock runner lock").clone() } /// Make every `start` fail with `message` (`None` restores success). pub fn fail_starts(&self, message: Option<&str>) { *self.start_error.lock().expect("mock runner lock") = message.map(str::to_string); } /// Script the `status` answer for one unit. pub fn set_status(&self, unit: &str, status: UnitStatus) { self.statuses .lock() .expect("mock runner lock") .insert(unit.to_string(), status); } /// Script the journal text every `log` call returns. pub fn set_log(&self, text: &str) { *self.log_text.lock().expect("mock runner lock") = text.to_string(); } fn record(&self, call: String) { self.calls.lock().expect("mock runner lock").push(call); } } #[async_trait] impl JobRunner for MockRunner { async fn start(&self, unit: &str) -> Result<(), String> { self.record(format!("start {unit}")); match self.start_error.lock().expect("mock runner lock").clone() { Some(message) => Err(message), None => Ok(()), } } async fn status(&self, unit: &str) -> Result { self.record(format!("status {unit}")); Ok(self .statuses .lock() .expect("mock runner lock") .get(unit) .cloned() .unwrap_or_else(|| UnitStatus { active_state: "inactive".into(), sub_state: "dead".into(), result: "success".into(), ..UnitStatus::default() })) } async fn log(&self, unit: &str, lines: usize) -> Result { self.record(format!("log {unit} {lines}")); Ok(self.log_text.lock().expect("mock runner lock").clone()) } } pub struct WebState { pub jobs: std::sync::Arc, pub started_at: Timestamp, pub config_mtime: Mutex>, } impl fmt::Debug for WebState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("WebState") .field("started_at", &self.started_at) .field("config_mtime", &self.config_mtime) .finish_non_exhaustive() } } impl WebState { /// Config reload on mtime (dashboard plan §4.2): when `config_path`'s /// modification time differs from the cached one, re-run `Config::load` /// and swap the live config. Returns `Ok(true)` when a reload happened, /// `Ok(false)` when nothing changed or no file is configured, and the /// load error when the file on disk no longer loads — the previous /// config stays live and the cached mtime is left alone so the next call /// tries again. Called by the settings page and by job starts. pub fn reload_if_changed( state: &crate::server::AppState, ) -> Result { let Some(path) = state.config_path.as_deref() else { return Ok(false); }; let mtime = std::fs::metadata(path) .and_then(|metadata| metadata.modified()) .ok(); let mut cached = state .web .config_mtime .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if *cached == mtime { return Ok(false); } let config = crate::config::Config::load(Some(path))?; match state.config.write() { Ok(mut live) => *live = std::sync::Arc::new(config), Err(poisoned) => *poisoned.into_inner() = std::sync::Arc::new(config), } *cached = mtime; tracing::info!(path = %path.display(), "reloaded configuration from disk"); Ok(true) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Flash { pub kind: String, pub text: String, } /// The site-wide ``, used by every page that does not /// set one of its own. Search engines truncate around 160 characters. pub const DEFAULT_DESCRIPTION: &str = concat!( "A daily newspaper of the web: articles hand-picked from one reader's feeds, ", "published every morning as an EPUB and readable here." ); #[derive(Debug, Clone)] pub struct Page { pub title: String, /// The `` for this page; `DEFAULT_DESCRIPTION` /// unless a handler overrides it with [`Page::with_description`]. pub description: String, pub viewer: Option, pub flash: Option, pub active_nav: String, pub version: &'static str, /// Cache-busting token for every `/static/*` URL the site references: a /// content hash, so any asset change reaches browsers and edge caches that /// hold the previous build (a `?v=`-carrying `/static/*` URL is served with /// an immutable one-year `max-age`). pub asset_version: &'static str, } const NEWSREADER: &[u8] = include_bytes!("static/fonts/Newsreader.woff2"); const NEWSREADER_ITALIC: &[u8] = include_bytes!("static/fonts/Newsreader-italic.woff2"); /// The stylesheet with both Newsreader faces pointed at versioned URLs. /// /// The faces used to be inlined here as `data:` URIs on the theory that a font /// fetched by URL lands after first paint and flashes. That theory was wrong: /// the flash of unstyled content came from script ordering (a parser-blocking /// `theme.js` ahead of the stylesheet let Gecko paint before the sheet applied, /// Bugzilla 1459305), and moving the script after the `` fixed it. What /// the inlining did cost was real: it tripled the render-blocking stylesheet to /// ~305 KB and pushed first paint out by more than a second on mobile. /// /// So the URLs stay URLs, carrying `?v=` so the immutable /// one-year `max-age` on a versioned `/static/*` URL is safe across deploys. /// The fonts are part of the `ASSET_VERSION` hash, so a new face mints a new /// URL. Neither face is preloaded — that only takes bandwidth from this sheet, /// which is what first paint actually waits on. Instead both use /// `font-display: swap` behind metric-matched local fallbacks, so first paint /// is immediate and the swap shifts nothing. pub static APP_CSS: LazyLock = LazyLock::new(|| { let version = ASSET_VERSION.as_str(); include_str!("static/app.css") .replace( "url(/static/Newsreader.woff2)", &format!("url(/static/Newsreader.woff2?v={version})"), ) .replace( "url(/static/Newsreader-italic.woff2)", &format!("url(/static/Newsreader-italic.woff2?v={version})"), ) }); /// First 12 hex digits of the SHA-256 over *every* embedded static asset. /// /// Every file listed here must also be referenced with `?v={ASSET_VERSION}`, /// and every file referenced with `?v=` must be hashed here — the two halves /// are what make the immutable one-year `max-age` safe. The favicon and the /// speculation rules are in the hash for exactly that reason: they used to be /// referenced by bare URL while still being served `immutable`, so editing /// either one could never have reached a browser (or the CDN) again. pub static ASSET_VERSION: LazyLock = LazyLock::new(|| { let mut hasher = Sha256::new(); hasher.update(include_str!("static/app.css")); hasher.update(NEWSREADER); hasher.update(NEWSREADER_ITALIC); hasher.update(include_str!("static/app.js")); hasher.update(include_str!("static/theme.js")); hasher.update(include_str!("static/favicon.svg")); hasher.update(include_str!("static/speculation.json")); hex::encode(hasher.finalize())[..12].to_string() }); /// The `Speculation-Rules` header value: a versioned URL, so a change to /// `speculation.json` is picked up rather than pinned behind the immutable /// one-year `max-age` on `/static/*`. Built once, like [`APP_CSS`], because it /// interpolates [`ASSET_VERSION`] and so cannot be a `from_static`. static SPECULATION_RULES: LazyLock = LazyLock::new(|| { HeaderValue::from_str(&format!( "\"/static/speculation.json?v={}\"", ASSET_VERSION.as_str() )) .expect("the asset version is hex, so the header value is valid") }); impl Page { pub fn new(title: impl Into, viewer: Option, active_nav: &str) -> Self { Self { title: title.into(), description: DEFAULT_DESCRIPTION.to_string(), viewer, flash: None, active_nav: active_nav.to_string(), version: crate::VERSION, asset_version: ASSET_VERSION.as_str(), } } /// Replace the site-wide description with one written for this page. #[must_use] pub fn with_description(mut self, text: impl Into) -> Self { self.description = text.into(); self } pub fn is_admin(&self) -> bool { self.viewer .as_ref() .is_some_and(|viewer| viewer.role == users::Role::Admin) } pub fn is_dashboard(&self) -> bool { self.is_admin() && matches!( self.active_nav.as_str(), "dashboard" | "runs" | "articles" | "ratings" | "profile" | "stats" | "jobs" | "settings" | "users" ) } } /// Consume the one-shot flash message for this page, if there is one. /// /// Reads before removing: `Session::remove` marks the session modified even /// when the key is absent, and a modified session is written back to SQLite /// at the end of the request, so the naive version cost every signed-in page /// a write. Because a session is only saved when modified, its inactivity /// expiry only slides when something changes it; [`touch_session`] keeps the /// `server.session_days` window sliding by writing at most once a day. pub async fn take_flash(session: &Session) -> Result, WebError> { let flash = session .get::(FLASH_KEY) .await .map_err(session_error)?; if flash.is_some() { session .remove_value(FLASH_KEY) .await .map_err(session_error)?; return Ok(flash); } touch_session(session).await?; Ok(None) } const FLASH_KEY: &str = "flash"; /// Session key holding the unix time of the last expiry-extending write. const TOUCHED_KEY: &str = "touched_at"; /// How often a signed-in session is written just to slide its expiry. const TOUCH_INTERVAL_SECS: i64 = 24 * 60 * 60; /// Slide a signed-in session's inactivity expiry, at most once a day. /// /// Only a session that already carries a login is touched. An anonymous /// request must never create a session: the cookie would follow that reader /// to every public page and take them out of the shared cache. async fn touch_session(session: &Session) -> Result<(), WebError> { let signed_in = session .get_value(session::AUTH_DATA_KEY) .await .map_err(session_error)? .is_some(); if !signed_in { return Ok(()); } let now = Timestamp::now().as_second(); let last = session .get::(TOUCHED_KEY) .await .map_err(session_error)?; if last.is_none_or(|last| now - last >= TOUCH_INTERVAL_SECS) { session .insert(TOUCHED_KEY, now) .await .map_err(session_error)?; } Ok(()) } fn session_error(error: axum_login::tower_sessions::session::Error) -> WebError { WebError::Internal(error.into()) } /// `Server-Timing` on every response, so the browser's DevTools (or /// `curl -sI`) can split origin work from the network and then split the /// origin work up again. See [`timing`] for the metrics and how they are /// collected. pub use self::timing::{route_boundary, server_timing}; /// Refuse TLS 1.3 0-RTT data for anything but a safe method (RFC 8470 §5.2). /// /// With `ssl_early_data on`, nginx forwards `Early-Data: 1` for a request the /// browser sent inside the handshake. A replayed early-data `GET` is /// harmless; a replayed `POST` (a rating, a login attempt, a job start) is /// not, so those get 425 and the browser resends after the handshake. pub async fn reject_early_data(request: Request, next: Next) -> Response { let early = request .headers() .get("early-data") .is_some_and(|value| value == "1"); if early && !request.method().is_safe() { return ( StatusCode::TOO_EARLY, [(header::CACHE_CONTROL, "no-store")], "retry after the TLS handshake completes", ) .into_response(); } next.run(request).await } pub struct Html(pub T); impl IntoResponse for Html { fn into_response(self) -> Response { let started = std::time::Instant::now(); let rendered = self.0.render(); timing::record_render(started.elapsed()); match rendered { Ok(body) => ( StatusCode::OK, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], body, ) .into_response(), Err(error) => { tracing::error!(%error, "rendering web template failed"); error_page_response( StatusCode::INTERNAL_SERVER_ERROR, "Server error", "The request could not be completed.", ) } } } } #[derive(Debug, thiserror::Error)] pub enum WebError { #[error("not found")] NotFound, #[error("forbidden")] Forbidden, #[error("authentication required")] Unauthenticated { next: String }, #[error("bad request: {0}")] BadRequest(String), /// BookOrbit has not indexed the requested issue yet. #[error("service unavailable: {0}")] ServiceUnavailable(String), /// A BookOrbit OPDS request failed. #[error("bad gateway: {0}")] BadGateway(String), #[error("request origin did not match this site")] Csrf, #[error(transparent)] Db(#[from] crate::db::DbError), #[error(transparent)] Internal(#[from] anyhow::Error), } #[derive(Template)] #[template(path = "error.html")] struct ErrorTemplate { page: Page, heading: String, message: String, } fn error_page_response(status: StatusCode, heading: &str, message: &str) -> Response { let rendered = ErrorTemplate { page: Page::new(heading, None, ""), heading: heading.to_string(), message: message.to_string(), } .render() .unwrap_or_else(|_| message.to_string()); ( status, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], rendered, ) .into_response() } impl IntoResponse for WebError { fn into_response(self) -> Response { if let Self::Unauthenticated { next } = self { return axum::response::Redirect::temporary(&format!( "/login?next={}", encode_component(&next) )) .into_response(); } let (status, heading, message) = match self { Self::NotFound => ( StatusCode::NOT_FOUND, "Not found", "That page does not exist.", ), Self::Forbidden | Self::Csrf => ( StatusCode::FORBIDDEN, "Forbidden", "You do not have permission to do that.", ), Self::BadRequest(ref message) => { (StatusCode::BAD_REQUEST, "Bad request", message.as_str()) } Self::ServiceUnavailable(ref message) => ( StatusCode::SERVICE_UNAVAILABLE, "Not indexed yet", message.as_str(), ), Self::BadGateway(ref message) => { (StatusCode::BAD_GATEWAY, "BookOrbit error", message.as_str()) } Self::Db(ref error) => { tracing::error!(%error, "web database request failed"); ( StatusCode::INTERNAL_SERVER_ERROR, "Server error", "The request could not be completed.", ) } Self::Internal(ref error) => { tracing::error!(%error, "web request failed"); ( StatusCode::INTERNAL_SERVER_ERROR, "Server error", "The request could not be completed.", ) } Self::Unauthenticated { .. } => unreachable!(), }; error_page_response(status, heading, message) } } pub fn encode_component(value: &str) -> String { url::form_urlencoded::byte_serialize(value.as_bytes()).collect() } #[derive(Debug, Clone, Copy)] pub struct Pagination { pub page: u32, pub per_page: u32, pub total: i64, } impl Pagination { pub fn offset(self) -> i64 { i64::from(self.page.saturating_sub(1)) * i64::from(self.per_page) } pub fn pages(self) -> u32 { ((self.total.max(0) as u64).div_ceil(u64::from(self.per_page))) as u32 } } pub fn format_time(timestamp: Timestamp, config: &crate::config::Config) -> String { config .tz() .map(|tz| { timestamp .to_zoned(tz) .strftime("%Y-%m-%d %H:%M %Z") .to_string() }) .unwrap_or_else(|_| timestamp.to_string()) } pub async fn security_headers(request: Request, next: Next) -> Response { let path = request.uri().path().to_string(); let mut response = next.run(request).await; let headers = response.headers_mut(); headers.insert( header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static( "default-src 'self'; img-src * data:; font-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; form-action 'self'", ), ); headers.insert( header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"), ); headers.insert( header::REFERRER_POLICY, HeaderValue::from_static("strict-origin-when-cross-origin"), ); if path.starts_with("/dashboard") { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); } // Fail closed. A response that names no policy of its own — `/login`, // `/account`, a redirect, an error page, or whatever route is added next — // is uncacheable, because a CDN with a cache-everything rule would // otherwise apply its own default TTL (Cloudflare: two hours on a 200) to // a page that may well be personalised. Handlers that mean to be cached // say so explicitly, and this never overrides them (§3.12). if !headers.contains_key(header::CACHE_CONTROL) { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); } if headers .get(header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.starts_with("text/html")) { headers.append(header::VARY, HeaderValue::from_static("Cookie")); headers.insert( header::HeaderName::from_static("speculation-rules"), SPECULATION_RULES.clone(), ); } response } pub fn router(config: &crate::config::Config) -> axum::Router { use axum::middleware::from_fn; use axum::routing::{get, post}; use axum_login::{login_required, permission_required}; use tower_governor::GovernorLayer; use tower_governor::governor::GovernorConfigBuilder; use tower_governor::key_extractor::SmartIpKeyExtractor; let seconds_per_token = (u64::from(config.server.login_window_minutes) * 60 / u64::from(config.server.login_attempts)) .max(1); let governor = std::sync::Arc::new( GovernorConfigBuilder::default() .per_second(seconds_per_token) .burst_size(config.server.login_attempts) .key_extractor(SmartIpKeyExtractor) .finish() .expect("validated non-zero login governor configuration"), ); if let Ok(handle) = tokio::runtime::Handle::try_current() { let cleanup = governor.clone(); handle.spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); loop { interval.tick().await; cleanup.limiter().retain_recent(); } }); } let login = axum::Router::new() .route("/login", get(session::login_page)) .route( "/login", post(session::login).route_layer(GovernorLayer::new(governor)), ); let account = axum::Router::new() .route("/account", get(session::account)) .route("/account/password", post(session::change_password)) .route("/account/logout-all", post(session::logout_everywhere)) .route("/logout", post(session::logout)) .route_layer(login_required!( session::Backend, login_url = "/login", redirect_field = "next" )); let full_issues = axum::Router::new() .route("/issues/{date}/articles/{article_id}", get(issue::article)) .route("/issues/{date}/world", get(issue::world)) .route("/issues/{date}/behind", get(issue::behind)) .route("/issues/{date}/read", get(issue::read)) .route_layer(login_required!( session::Backend, login_url = "/login", redirect_field = "next" )) .route_layer(from_fn(map_forbidden)); let dashboard = axum::Router::new() .merge(dashboard::router()) .route("/rate", post(rate::post)) .route_layer(permission_required!( session::Backend, login_url = "/login", redirect_field = "next", users::Role::Admin )) .route_layer(from_fn(map_forbidden)); axum::Router::new() .route("/", get(public::latest)) .route("/issues", get(public::archive)) .route("/issues/{date}", get(public::show_issue)) .route("/feed.xml", get(public::feed)) .route("/robots.txt", get(public::robots)) .route("/static/{file}", get(static_asset)) .merge(login) .merge(account) .merge(full_issues) .merge(dashboard) .fallback(|| async { WebError::NotFound }) } async fn map_forbidden(request: Request, next: Next) -> Response { let mut response = next.run(request).await; if response.status() == StatusCode::FORBIDDEN { WebError::Forbidden.into_response() } else { if response.status() == StatusCode::TEMPORARY_REDIRECT { *response.status_mut() = StatusCode::FOUND; } response } } /// The `?v=` cache-buster on a `/static/*` URL, when the reference carries one. #[derive(Debug, Deserialize)] struct AssetQuery { #[serde(default)] v: Option, } /// A versioned URL names one immutable build, so it may be held for a year. const VERSIONED_CACHE: &str = "public, max-age=31536000, immutable"; /// A bare `/static/…` URL is not a promise about its content, so it gets an /// hour and revalidates against the ETag. Someone else's link or an old /// bookmark must not pin a stale asset for a year. const UNVERSIONED_CACHE: &str = "public, max-age=3600"; async fn static_asset( axum::extract::Path(file): axum::extract::Path, axum::extract::Query(query): axum::extract::Query, headers: axum::http::HeaderMap, ) -> Response { let asset: (&str, &'static [u8]) = match file.as_str() { "app.css" => ("text/css; charset=utf-8", APP_CSS.as_bytes()), "app.js" => ( "application/javascript; charset=utf-8", include_str!("static/app.js").as_bytes(), ), "theme.js" => ( "application/javascript; charset=utf-8", include_str!("static/theme.js").as_bytes(), ), "speculation.json" => ( "application/speculationrules+json", include_str!("static/speculation.json").as_bytes(), ), "favicon.svg" => ( "image/svg+xml", include_str!("static/favicon.svg").as_bytes(), ), "Newsreader.woff2" => ("font/woff2", NEWSREADER), "Newsreader-italic.woff2" => ("font/woff2", NEWSREADER_ITALIC), _ => return WebError::NotFound.into_response(), }; let cache_control = if query.v.is_some() { VERSIONED_CACHE } else { UNVERSIONED_CACHE }; let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1))); if headers .get(header::IF_NONE_MATCH) .and_then(|value| value.to_str().ok()) == Some(etag.as_str()) { return ( StatusCode::NOT_MODIFIED, [ (header::ETAG, etag), (header::CACHE_CONTROL, cache_control.to_string()), ], ) .into_response(); } ( StatusCode::OK, [ (header::CONTENT_TYPE, asset.0.to_string()), (header::CACHE_CONTROL, cache_control.to_string()), (header::ETAG, etag), ], asset.1, ) .into_response() } #[cfg(test)] mod tests { use axum::body::{Body, to_bytes}; use axum::http::{Method, Request, header}; use tower::ServiceExt; use super::*; use crate::config::Config; use crate::db::Db; use crate::server::{AppState, router}; /// The `db` metric only fills in when [`timing::sqlx_timing_layer`] is /// installed, and it has to be installed *globally*: sqlx times statements /// on a worker thread, which sees no thread-local default. Global means /// once per test binary, hence the `Once`; nothing is written anywhere, so /// the tests that do not care never notice it. fn install_timing_layer() { use tracing_subscriber::layer::SubscriberExt as _; static INSTALLED: std::sync::Once = std::sync::Once::new(); INSTALLED.call_once(|| { let subscriber = tracing_subscriber::registry().with(timing::sqlx_timing_layer()); let _ = tracing::subscriber::set_global_default(subscriber); }); } async fn test_state(config: Config) -> (tempfile::TempDir, AppState) { let dir = tempfile::tempdir().unwrap(); let db = Db::open_and_migrate(&dir.path().join("db.sqlite")) .await .unwrap(); (dir, AppState::new(db, config, None)) } fn post(uri: &str, body: &str, ip: &str) -> Request { Request::builder() .method(Method::POST) .uri(uri) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header("sec-fetch-site", "same-origin") .header("x-forwarded-for", ip) .body(Body::from(body.to_string())) .unwrap() } async fn login_cookie(app: &axum::Router, username: &str, password: &str) -> String { let response = app .clone() .oneshot(post( "/login", &format!("username={username}&password={password}&next=%2F"), "192.0.2.1", )) .await .unwrap(); assert_eq!(response.status(), StatusCode::SEE_OTHER); response .headers() .get(header::SET_COOKIE) .unwrap() .to_str() .unwrap() .split(';') .next() .unwrap() .to_string() } async fn response_text(response: Response) -> String { String::from_utf8( to_bytes(response.into_body(), 1024 * 1024) .await .unwrap() .to_vec(), ) .unwrap() } #[tokio::test] async fn take_flash_reads_without_dirtying_an_empty_session() { let (_dir, state) = test_state(Config::default()).await; let store = std::sync::Arc::new(session::SqliteSessionStore::new(state.db.pool().clone())); let session = Session::new(None, store, None); assert_eq!(take_flash(&session).await.unwrap(), None); assert!(!session.is_modified()); assert!(session.is_empty().await); let flash = Flash { kind: "ok".into(), text: "saved".into(), }; session.insert(FLASH_KEY, &flash).await.unwrap(); assert_eq!(take_flash(&session).await.unwrap(), Some(flash)); assert_eq!(take_flash(&session).await.unwrap(), None); } #[tokio::test] async fn signed_in_pages_touch_the_session_once_a_day_not_every_request() { let mut config = Config::default(); config.server.public_url = "https://daily.example".into(); let (_dir, state) = test_state(config).await; // Only the dashboard and the signed-in issue pages consume flashes, so // an admin exercises the path without an issue in the database. users::add(&state.db, "admin", "correct horse battery", true) .await .unwrap(); let app = router(state.clone()); let cookie = login_cookie(&app, "admin", "correct horse battery").await; let stamp = || async { sqlx::query_scalar::<_, String>("SELECT updated_at || ' ' || data FROM sessions") .fetch_one(state.db.pool()) .await .unwrap() }; let get = |uri: &str| { Request::builder() .uri(uri) .header(header::COOKIE, cookie.clone()) .body(Body::empty()) .unwrap() }; // The first page after login writes the daily touch stamp… let response = app .clone() .oneshot(get("/dashboard/articles")) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let first = stamp().await; assert!(first.contains(TOUCHED_KEY), "{first}"); // …and the pages after it leave the row alone. for uri in [ "/dashboard/articles", "/dashboard/ratings", "/dashboard/stats", "/", "/account", ] { let response = app.clone().oneshot(get(uri)).await.unwrap(); assert_eq!(response.status(), StatusCode::OK, "{uri}"); } assert_eq!(stamp().await, first); } #[tokio::test] async fn server_timing_header_and_early_data_guard() { let (_dir, state) = test_state(Config::default()).await; let app = router(state); let response = app .clone() .oneshot( Request::builder() .uri("/healthz") .body(Body::empty()) .unwrap(), ) .await .unwrap(); let timing = response .headers() .get("server-timing") .unwrap() .to_str() .unwrap(); assert!(timing.starts_with("app;dur="), "{timing}"); // `/healthz` is routed but touches neither SQLite nor a template. assert!(timing.contains("sess;dur="), "{timing}"); assert!(!timing.contains("db;dur="), "{timing}"); assert!(!timing.contains("tpl;dur="), "{timing}"); // A safe method may arrive as 0-RTT data; a POST may not. let response = app .clone() .oneshot( Request::builder() .uri("/healthz") .header("early-data", "1") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let mut request = post("/login", "username=a&password=b", "192.0.2.1"); request .headers_mut() .insert("early-data", HeaderValue::from_static("1")); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::TOO_EARLY); } /// The `db` metric crosses a thread boundary to get here: sqlx runs the /// statement on the connection's worker thread and only reports the /// elapsed time there, inside the span this request handed it. This is the /// test that the hand-off actually holds together. #[tokio::test] async fn server_timing_breaks_the_request_into_session_db_and_template() { install_timing_layer(); let (_dir, state) = test_state(Config::default()).await; users::add(&state.db, "admin", "correct horse battery", true) .await .unwrap(); let app = router(state); let cookie = login_cookie(&app, "admin", "correct horse battery").await; let response = app .oneshot( Request::builder() .uri("/dashboard/articles") .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let timing = response .headers() .get("server-timing") .unwrap() .to_str() .unwrap() .to_string(); // Every metric is present, and each is a number the spec allows. assert!(timing.starts_with("app;dur="), "{timing}"); let mut durations = std::collections::HashMap::new(); for metric in timing.split(", ") { let (name, rest) = metric.split_once(";dur=").expect(&timing); let number = rest.split(';').next().unwrap(); durations.insert(name, number.parse::().expect(&timing)); } for name in ["app", "sess", "db", "tpl"] { assert!(durations.contains_key(name), "{name} missing from {timing}"); } // A dashboard page loads the session and renders, so each of those is // inside the whole rather than beside it. `db` is left out: it is a // sum of statement times, which a handler running queries concurrently // could legitimately push past the wall clock. let app_dur = durations["app"]; for name in ["sess", "tpl"] { assert!(durations[name] <= app_dur, "{name} exceeds app in {timing}"); } // `sess` is the session load and save; `db` includes those statements // and the page's own, so neither is a subset of the other, but both // had to have measured something. assert!(durations["db"] > 0.0, "{timing}"); // The statement count rides along in `desc`, which is where DevTools // shows it. A dashboard page runs several: session, user, articles. let db = timing .split(", ") .find(|metric| metric.starts_with("db;")) .expect(&timing); let count: u32 = db .split("desc=\"") .nth(1) .and_then(|desc| desc.split_whitespace().next()) .and_then(|count| count.parse().ok()) .unwrap_or_else(|| panic!("no statement count in {db}")); assert!(count > 1, "{db}"); assert!(db.ends_with(" queries\""), "{db}"); } #[tokio::test] async fn login_cookie_account_logout_and_anonymous_pages() { let mut config = Config::default(); config.server.public_url = "https://daily.example".into(); let (_dir, state) = test_state(config).await; users::add(&state.db, "admin", "correct horse battery", true) .await .unwrap(); let app = router(state.clone()); let anonymous = app .clone() .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) .await .unwrap(); assert!(anonymous.headers().get(header::SET_COOKIE).is_none()); assert_eq!( anonymous.headers().get(header::CACHE_CONTROL).unwrap(), "public, max-age=300" ); assert_eq!( anonymous.headers().get("speculation-rules").unwrap(), &format!("\"/static/speculation.json?v={}\"", ASSET_VERSION.as_str()) ); let response = app .clone() .oneshot(post( "/login", "username=admin&password=correct+horse+battery&next=%2Faccount", "192.0.2.3", )) .await .unwrap(); assert_eq!(response.status(), StatusCode::SEE_OTHER); assert_eq!( response.headers().get(header::LOCATION).unwrap(), "/account" ); let set_cookie = response .headers() .get(header::SET_COOKIE) .unwrap() .to_str() .unwrap(); assert!(set_cookie.contains("daily_session=")); assert!(set_cookie.contains("HttpOnly")); assert!(set_cookie.contains("SameSite=Lax")); assert!(set_cookie.contains("Secure")); let cookie = set_cookie.split(';').next().unwrap(); let account = app .clone() .oneshot( Request::builder() .uri("/account") .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(account.status(), StatusCode::OK); assert!(response_text(account).await.contains("admin")); let logout = app .clone() .oneshot( Request::builder() .method(Method::POST) .uri("/logout") .header(header::COOKIE, cookie) .header("sec-fetch-site", "same-origin") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(logout.status(), StatusCode::SEE_OTHER); } #[tokio::test] async fn guards_distinguish_anonymous_users_and_admins() { let (_dir, state) = test_state(Config::default()).await; users::add(&state.db, "reader", "correct horse battery", false) .await .unwrap(); users::add(&state.db, "admin", "correct horse battery", true) .await .unwrap(); let app = router(state); let anonymous = app .clone() .oneshot( Request::builder() .uri("/dashboard") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(anonymous.status(), StatusCode::FOUND); assert_eq!( anonymous.headers().get(header::LOCATION).unwrap(), "/login?next=%2Fdashboard" ); let anonymous_rate = app .clone() .oneshot(post("/rate", "", "192.0.2.20")) .await .unwrap(); assert_eq!(anonymous_rate.status(), StatusCode::FOUND); assert_eq!( anonymous_rate.headers().get(header::LOCATION).unwrap(), "/login?next=%2Frate" ); let reader = login_cookie(&app, "reader", "correct horse battery").await; let forbidden = app .clone() .oneshot( Request::builder() .uri("/dashboard") .header(header::COOKIE, &reader) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(forbidden.status(), StatusCode::FORBIDDEN); assert!(response_text(forbidden).await.contains("Forbidden")); let forbidden_rate = app .clone() .oneshot( Request::builder() .method(Method::POST) .uri("/rate") .header(header::COOKIE, &reader) .header("sec-fetch-site", "same-origin") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(forbidden_rate.status(), StatusCode::FORBIDDEN); let admin = login_cookie(&app, "admin", "correct horse battery").await; let allowed = app .clone() .oneshot( Request::builder() .uri("/dashboard") .header(header::COOKIE, &admin) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(allowed.status(), StatusCode::OK); assert_eq!( allowed.headers().get(header::CACHE_CONTROL).unwrap(), "no-store" ); let settings = app .clone() .oneshot( Request::builder() .uri("/dashboard/settings") .header(header::COOKIE, &admin) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(settings.status(), StatusCode::OK); let settings = response_text(settings).await; assert!(settings.contains("Dashboard"), "{settings}"); assert!(!settings.contains("Morning edition"), "{settings}"); assert!( settings.contains( "border-accent text-ink\" href=\"/dashboard/settings\" aria-current=\"page\"" ), "{settings}" ); let allowed_rate = app .oneshot( Request::builder() .method(Method::POST) .uri("/rate") .header(header::COOKIE, admin) .header("sec-fetch-site", "same-origin") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(allowed_rate.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn origin_check_rejects_cross_site_and_foreign_origins() { let (_dir, state) = test_state(Config::default()).await; let app = router(state); let cross = app .clone() .oneshot( Request::builder() .method(Method::POST) .uri("/login") .header("sec-fetch-site", "cross-site") .header("x-forwarded-for", "192.0.2.10") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(cross.status(), StatusCode::FORBIDDEN); let foreign = app .clone() .oneshot( Request::builder() .method(Method::POST) .uri("/login") .header(header::ORIGIN, "https://evil.example") .header(header::HOST, "daily.hallada.net") .header("x-forwarded-proto", "https") .header("x-forwarded-for", "192.0.2.11") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(foreign.status(), StatusCode::FORBIDDEN); let same = app .oneshot( Request::builder() .method(Method::POST) .uri("/login") .header(header::ORIGIN, "https://daily.hallada.net") .header(header::HOST, "daily.hallada.net") .header("x-forwarded-proto", "https") .header("x-forwarded-for", "192.0.2.12") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from("username=x&password=invalid-invalid")) .unwrap(), ) .await .unwrap(); assert_eq!(same.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn login_throttle_is_per_ip() { let mut config = Config::default(); config.server.login_attempts = 3; let (_dir, state) = test_state(config).await; let app = router(state); for _ in 0..3 { let response = app .clone() .oneshot(post( "/login", "username=nobody&password=invalid-invalid", "192.0.2.20", )) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } let limited = app .clone() .oneshot(post( "/login", "username=nobody&password=invalid-invalid", "192.0.2.20", )) .await .unwrap(); assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS); let other = app .oneshot(post( "/login", "username=nobody&password=invalid-invalid", "192.0.2.21", )) .await .unwrap(); assert_eq!(other.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn disabled_users_and_password_changes_invalidate_other_sessions() { let (_dir, state) = test_state(Config::default()).await; users::add(&state.db, "reader", "correct horse battery", false) .await .unwrap(); let app = router(state.clone()); let first = login_cookie(&app, "reader", "correct horse battery").await; let second = login_cookie(&app, "reader", "correct horse battery").await; let changed = app .clone() .oneshot( Request::builder() .method(Method::POST) .uri("/account/password") .header(header::COOKIE, &first) .header("sec-fetch-site", "same-origin") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from("current_password=correct+horse+battery&new_password=a+replacement+password&confirm_password=a+replacement+password")) .unwrap(), ) .await .unwrap(); assert_eq!(changed.status(), StatusCode::SEE_OTHER); let old_session = app .clone() .oneshot( Request::builder() .uri("/account") .header(header::COOKIE, second) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert!(old_session.status().is_redirection()); let fresh = login_cookie(&app, "reader", "a replacement password").await; users::set_disabled(&state.db, "reader", true) .await .unwrap(); let disabled = app .oneshot( Request::builder() .uri("/account") .header(header::COOKIE, fresh) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert!(disabled.status().is_redirection()); } #[tokio::test] async fn files_accept_a_session_or_basic_auth() { let mut config = Config::default(); config.server.basic_auth_user = Some("opds".into()); config.server.basic_auth_pass = Some("hunter2".into()); let (dir, state) = test_state(config).await; let epub_dir = dir.path().join("epub"); std::fs::create_dir_all(&epub_dir).unwrap(); std::fs::write(epub_dir.join("issue.epub"), b"epub").unwrap(); { let mut live = state.config.write().unwrap(); let mut changed = (**live).clone(); changed.publish.epub_dir = epub_dir; *live = std::sync::Arc::new(changed); } users::add(&state.db, "reader", "correct horse battery", false) .await .unwrap(); let app = router(state); let anonymous = app .clone() .oneshot( Request::builder() .uri("/files/epub/issue.epub") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED); assert!(anonymous.headers().contains_key(header::WWW_AUTHENTICATE)); let basic = app .clone() .oneshot( Request::builder() .uri("/files/epub/issue.epub") .header(header::AUTHORIZATION, "Basic b3BkczpodW50ZXIy") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(basic.status(), StatusCode::OK); let cookie = login_cookie(&app, "reader", "correct horse battery").await; let session = app .oneshot( Request::builder() .uri("/files/epub/issue.epub") .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(session.status(), StatusCode::OK); } #[test] fn asset_urls_carry_a_content_hash_not_the_crate_version() { let page = Page::new("t", None, "latest"); assert_eq!(page.asset_version.len(), 12); assert!(page.asset_version.chars().all(|c| c.is_ascii_hexdigit())); assert_ne!(page.asset_version, crate::VERSION); let html = ErrorTemplate { page, heading: "h".into(), message: "m".into(), } .render() .unwrap(); let expected = format!("/static/app.css?v={}", ASSET_VERSION.as_str()); assert!(html.contains(&expected), "{html}"); assert!(!html.contains(&format!("/static/app.css?v={}", crate::VERSION))); // Nothing is preloaded. A preload of the 132 KB regular face shares // bandwidth with the 12 KB render-blocking stylesheet, which is what // first paint actually waits on: it pushed simulated FCP from ~1.0 s to // ~1.8 s on Lighthouse mobile, for a face the metric-matched fallbacks // already stand in for. assert!(!html.contains("rel=\"preload\""), "{html}"); // The italic face is never referenced from the HTML either. assert!(!html.contains("Newsreader-italic.woff2"), "{html}"); // `defer` keeps app.js out of Lighthouse's render-blocking list; it has // no readyState or DOMContentLoaded dependence, so deferring is safe. let app_js = format!( "", ASSET_VERSION.as_str() ); assert!(html.contains(&app_js), "{html}"); } #[test] fn pages_render_a_meta_description_and_escape_it() { let render = |page: Page| { ErrorTemplate { page, heading: "h".into(), message: "m".into(), } .render() .unwrap() }; // Every page carries a description; the default one when none is set. assert!(DEFAULT_DESCRIPTION.len() <= 160, "{DEFAULT_DESCRIPTION}"); let html = render(Page::new("t", None, "latest")); assert!( html.contains( "" ), "{html}" ); // A page-specific one replaces it, HTML-escaped into the attribute. let page = Page::new("t", None, "latest") .with_description("Issue \"No. 3\" & 4 for O'Donnell"); assert_eq!(page.description, "Issue \"No. 3\" & 4 for O'Donnell"); let html = render(page); assert!( html.contains( "" ), "{html}" ); assert!(!html.contains("4"), "{html}"); assert!(!html.contains(DEFAULT_DESCRIPTION), "{html}"); } #[test] fn stylesheet_points_both_newsreader_faces_at_versioned_urls() { let css = APP_CSS.as_str(); let version = ASSET_VERSION.as_str(); assert!( css.contains(&format!("url(/static/Newsreader.woff2?v={version})")), "regular face missing from stylesheet" ); assert!( css.contains(&format!("url(/static/Newsreader-italic.woff2?v={version})")), "italic face missing from stylesheet" ); // Unversioned references would be cached forever under a stale URL, and // inlined faces would put ~280 KB back into the render-blocking sheet. assert!(!css.contains("url(/static/Newsreader.woff2)")); assert!(!css.contains("url(/static/Newsreader-italic.woff2)")); assert!(!css.contains("data:font")); // `swap` paints immediately in the metric-matched local fallback. assert_eq!(css.matches("font-display:swap").count(), 2); assert!(!css.contains("font-display:block")); } #[tokio::test] async fn static_assets_use_content_hash_etags() { let (_dir, state) = test_state(Config::default()).await; let app = router(state); let versioned = format!("/static/app.css?v={}", ASSET_VERSION.as_str()); let first = app .clone() .oneshot( Request::builder() .uri(versioned.as_str()) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(first.status(), StatusCode::OK); assert_eq!( first.headers().get(header::CACHE_CONTROL).unwrap(), "public, max-age=31536000, immutable" ); let etag = first.headers().get(header::ETAG).unwrap().clone(); let cached = app .clone() .oneshot( Request::builder() .uri(versioned.as_str()) .header(header::IF_NONE_MATCH, etag.clone()) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(cached.status(), StatusCode::NOT_MODIFIED); assert_eq!( cached.headers().get(header::CACHE_CONTROL).unwrap(), "public, max-age=31536000, immutable" ); // Without `?v=` the URL is not a promise about its content: an hour, // revalidated against the same ETag, never a pinned year. let bare = app .clone() .oneshot( Request::builder() .uri("/static/app.css") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(bare.status(), StatusCode::OK); assert_eq!( bare.headers().get(header::CACHE_CONTROL).unwrap(), "public, max-age=3600" ); assert_eq!(bare.headers().get(header::ETAG).unwrap(), &etag); let bare_cached = app .clone() .oneshot( Request::builder() .uri("/static/app.css") .header(header::IF_NONE_MATCH, etag) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(bare_cached.status(), StatusCode::NOT_MODIFIED); assert_eq!( bare_cached.headers().get(header::CACHE_CONTROL).unwrap(), "public, max-age=3600" ); let rules = app .oneshot( Request::builder() .uri(format!( "/static/speculation.json?v={}", ASSET_VERSION.as_str() )) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(rules.status(), StatusCode::OK); assert_eq!( rules.headers().get(header::CONTENT_TYPE).unwrap(), "application/speculationrules+json" ); assert_eq!( rules.headers().get(header::CACHE_CONTROL).unwrap(), "public, max-age=31536000, immutable" ); } /// Everything the layout and the headers point at must carry `?v=`: an /// unversioned reference to an immutably cached asset can never be updated. #[tokio::test] async fn every_referenced_static_url_is_versioned() { let (_dir, state) = test_state(Config::default()).await; let app = router(state); let response = app .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) .await .unwrap(); let version = ASSET_VERSION.as_str(); assert_eq!( response.headers().get("speculation-rules").unwrap(), &format!("\"/static/speculation.json?v={version}\"") ); let html = response_text(response).await; for asset in ["favicon.svg", "app.css", "theme.js"] { assert!( html.contains(&format!("/static/{asset}?v={version}")), "{asset} is referenced without ?v= in {html}" ); assert!( !html.contains(&format!("\"/static/{asset}\"")), "{asset} still has a bare reference in {html}" ); } // The fonts are reached through the stylesheet, not the layout. assert!(APP_CSS.contains(&format!("url(/static/Newsreader.woff2?v={version})"))); } /// A response that names no policy of its own must not be cacheable: a CDN /// cache-everything rule would otherwise give it the CDN's default TTL. #[tokio::test] async fn responses_without_a_policy_default_to_no_store() { let (_dir, state) = test_state(Config::default()).await; let app = router(state); for uri in ["/login", "/healthz", "/no-such-page"] { let response = app .clone() .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!( response.headers().get(header::CACHE_CONTROL).unwrap(), "no-store", "{uri}" ); } } /// Cookie- and Basic-auth-gated routes must be uncacheable everywhere, on /// every status code, whatever the CDN is told to do. #[tokio::test] async fn download_and_opds_routes_are_private_no_store() { let mut config = Config::default(); config.publish.epub_dir = std::path::PathBuf::from("/nonexistent/epub"); config.publish.xtc_dir = std::path::PathBuf::from("/nonexistent/xtc"); let (_dir, state) = test_state(config).await; let app = router(state); for uri in [ "/opds", "/opds/", crate::publish::OPDS_PATH, "/files/epub/missing.epub", "/files/xtc/missing.xtch", "/files/epub/..%2Fescape", ] { let response = app .clone() .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!( response.headers().get(header::CACHE_CONTROL).unwrap(), "private, no-store", "{uri} answered {}", response.status() ); } // The Basic auth challenge is a response too. let mut config = Config::default(); config.server.basic_auth_user = Some("daily".into()); config.server.basic_auth_pass = Some("hunter2".into()); let (_dir, state) = test_state(config).await; let app = router(state); let challenge = app .oneshot(Request::builder().uri("/opds").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(challenge.status(), StatusCode::UNAUTHORIZED); assert_eq!( challenge.headers().get(header::CACHE_CONTROL).unwrap(), "private, no-store" ); } #[tokio::test] async fn not_found_and_server_error_pages_use_the_site_layout() { let (_dir, state) = test_state(Config::default()).await; let app = router(state); let missing = app .oneshot( Request::builder() .uri("/no-such-page") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(missing.status(), StatusCode::NOT_FOUND); let missing = response_text(missing).await; assert!(missing.contains(""), "{missing}"); assert!(missing.contains("The Daily EPUB"), "{missing}"); assert!(missing.contains("That page does not exist"), "{missing}"); let failed = WebError::Internal(anyhow::anyhow!("fixture failure")).into_response(); assert_eq!(failed.status(), StatusCode::INTERNAL_SERVER_ERROR); let failed = response_text(failed).await; assert!(failed.contains(""), "{failed}"); assert!(failed.contains("The Daily EPUB"), "{failed}"); assert!( failed.contains("The request could not be completed"), "{failed}" ); assert!(!failed.contains("fixture failure"), "{failed}"); } }