Web dashboard step 1: migration, sessions, users CLI, public site

Migration 0004 (users, sessions, config_changes, profile_versions, jobs,
rating_events.user_id, runs.report_json, issues.issue_json), the issue
snapshot writer and loader, the web module skeleton with layout and static
assets, axum-login/tower-sessions over a sqlx session store, password-auth
users with a CLI, the login throttle, the origin check, security headers,
and the public issue pages, archive, Atom feed and robots.txt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
2026-09-03 04:38:22 +00:00
co-authored by Claude Fable 5.1
parent a70de4af06
commit 69cd736455
35 changed files with 3801 additions and 69 deletions
+914
View File
@@ -0,0 +1,914 @@
pub mod issue;
pub mod public;
pub mod session;
pub mod users;
use std::fmt;
use std::sync::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;
#[async_trait]
pub trait JobRunner: Send + Sync {
async fn start(&self, unit: &str) -> Result<(), String>;
async fn status(&self, unit: &str) -> Result<UnitStatus, String>;
async fn log(&self, unit: &str, lines: usize) -> Result<String, String>;
}
#[derive(Debug, Clone, Default)]
pub struct UnitStatus {
pub active_state: String,
pub sub_state: String,
pub result: String,
pub exit_status: Option<i32>,
}
#[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<UnitStatus, String> {
Err("jobs are disabled".into())
}
async fn log(&self, _unit: &str, _lines: usize) -> Result<String, String> {
Err("jobs are disabled".into())
}
}
/// In-memory runner for router tests. Step 6 will add scripted results alongside
/// these recorded calls when the jobs pages begin invoking the runner.
#[derive(Debug, Default)]
pub struct MockRunner {
calls: Mutex<Vec<String>>,
}
impl MockRunner {
pub fn calls(&self) -> Vec<String> {
self.calls.lock().expect("mock runner lock").clone()
}
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}"));
Ok(())
}
async fn status(&self, unit: &str) -> Result<UnitStatus, String> {
self.record(format!("status {unit}"));
Ok(UnitStatus::default())
}
async fn log(&self, unit: &str, lines: usize) -> Result<String, String> {
self.record(format!("log {unit} {lines}"));
Ok(String::new())
}
}
pub struct WebState {
pub jobs: std::sync::Arc<dyn JobRunner>,
pub started_at: Timestamp,
pub config_mtime: Mutex<Option<SystemTime>>,
}
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()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Flash {
pub kind: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct Page {
pub title: String,
pub viewer: Option<Viewer>,
pub flash: Option<Flash>,
pub active_nav: String,
pub version: &'static str,
}
impl Page {
pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self {
Self {
title: title.into(),
viewer,
flash: None,
active_nav: active_nav.to_string(),
version: crate::VERSION,
}
}
pub fn is_admin(&self) -> bool {
self.viewer
.as_ref()
.is_some_and(|viewer| viewer.role == users::Role::Admin)
}
}
pub struct Html<T: Template>(pub T);
impl<T: Template> IntoResponse for Html<T> {
fn into_response(self) -> Response {
match self.0.render() {
Ok(body) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
body,
)
.into_response(),
Err(error) => {
tracing::error!(%error, "rendering web template failed");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
}
}
}
}
#[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),
#[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,
}
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::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!(),
};
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()
}
}
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:; 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"));
}
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"));
}
response
}
pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::AppState> {
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 dashboard = axum::Router::new()
.route("/dashboard", get(dashboard_stub))
.route("/rate", post(rate_stub))
.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(dashboard)
}
#[derive(Template)]
#[template(path = "dashboard/overview.html")]
struct OverviewTemplate {
page: Page,
}
async fn dashboard_stub(auth: session::AuthSession) -> Result<Response, WebError> {
let viewer = auth.user().await.map(session::Viewer::from);
Ok(Html(OverviewTemplate {
page: Page::new("Overview", viewer, "dashboard"),
})
.into_response())
}
async fn rate_stub() -> StatusCode {
StatusCode::NOT_IMPLEMENTED
}
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
}
}
async fn static_asset(
axum::extract::Path(file): axum::extract::Path<String>,
headers: axum::http::HeaderMap,
) -> Response {
let asset = match file.as_str() {
"app.css" => ("text/css; charset=utf-8", include_str!("static/app.css")),
"app.js" => (
"application/javascript; charset=utf-8",
include_str!("static/app.js"),
),
"favicon.svg" => ("image/svg+xml", include_str!("static/favicon.svg")),
_ => return WebError::NotFound.into_response(),
};
let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1.as_bytes())));
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, "public, max-age=86400".into()),
],
)
.into_response();
}
(
StatusCode::OK,
[
(header::CONTENT_TYPE, asset.0.to_string()),
(header::CACHE_CONTROL, "public, max-age=86400".into()),
(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};
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<Body> {
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 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"
);
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 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::NOT_IMPLEMENTED);
}
#[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);
}
#[tokio::test]
async fn static_assets_use_content_hash_etags() {
let (_dir, state) = test_state(Config::default()).await;
let app = router(state);
let first = app
.clone()
.oneshot(
Request::builder()
.uri("/static/app.css")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
assert_eq!(
first.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=86400"
);
let etag = first.headers().get(header::ETAG).unwrap().clone();
let cached = app
.oneshot(
Request::builder()
.uri("/static/app.css")
.header(header::IF_NONE_MATCH, etag)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(cached.status(), StatusCode::NOT_MODIFIED);
}
}