Krolyxon
Commit e758199

ferreniumtestshttp.rs

215 lines · 7.6 KB · Rust

1//! Route-level tests driven through `tower::ServiceExt::oneshot`.
2
3use axum::body::{Body, to_bytes};
4use axum::http::{Request, StatusCode, header};
5use ferrenium::config::Config;
6use ferrenium::server;
7use ferrenium::state::{AppState, Overrides};
8use tower::ServiceExt;
9
10fn app_with(toml: &str) -> (axum::Router, std::sync::Arc<AppState>) {
11 let mut config = Config::parse(toml).expect("parse");
12 config.finish().expect("valid");
13 let state = AppState::new(config, Overrides::default());
14 (server::app(state.clone()), state)
15}
16
17async fn get(app: &axum::Router, uri: &str) -> (StatusCode, axum::http::HeaderMap, String) {
18 let res = app
19 .clone()
20 .oneshot(Request::get(uri).body(Body::empty()).unwrap())
21 .await
22 .unwrap();
23 let status = res.status();
24 let headers = res.headers().clone();
25 let body = to_bytes(res.into_body(), usize::MAX).await.unwrap();
26 (status, headers, String::from_utf8_lossy(&body).into_owned())
27}
28
29#[tokio::test]
30async fn index_renders_styled_empty_page() {
31 let (app, state) =
32 app_with("[site]\ntitle = \"krolyxon\"\ndescription = \"Personal <repos>\"\n");
33 let (status, headers, body) = get(&app, "/").await;
34 assert_eq!(status, StatusCode::OK);
35 assert_eq!(headers[header::CONTENT_TYPE], "text/html; charset=utf-8");
36 assert!(
37 headers[header::CONTENT_SECURITY_POLICY]
38 .to_str()
39 .unwrap()
40 .contains("script-src 'self'")
41 );
42 assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
43
44 let static_base = format!("/-/static/{}/", state.assets.hash());
45 assert!(
46 body.contains(&format!("{static_base}css/app.css")),
47 "{body}"
48 );
49 assert!(body.contains(&format!("{static_base}js/theme.js")));
50 assert!(body.contains(r#"data-default-theme="auto""#));
51 assert!(!body.contains("data-theme=\"")); // auto: OS decides
52 assert!(body.contains("krolyxon"));
53 // Escaped by askama.
54 assert!(body.contains("Personal &#60;repos&#62;") || body.contains("Personal &lt;repos&gt;"));
55 assert!(body.contains("No repositories"));
56 // No inline scripts or styles (CSP).
57 assert!(!body.contains("<script>"));
58 assert!(!body.contains("<style"));
59 assert!(!body.contains("style=\""));
60}
61
62#[tokio::test]
63async fn forced_theme_is_rendered_server_side() {
64 let (app, _) = app_with("[ui]\ntheme = \"light\"\n");
65 let (_, _, body) = get(&app, "/").await;
66 assert!(body.contains(r#"data-default-theme="light" data-theme="light""#));
67}
68
69#[tokio::test]
70async fn health() {
71 let (app, _) = app_with("");
72 let (status, headers, body) = get(&app, "/-/health").await;
73 assert_eq!(status, StatusCode::OK);
74 assert_eq!(body, "ok\n");
75 assert_eq!(headers[header::CACHE_CONTROL], "no-store");
76}
77
78#[tokio::test]
79async fn static_assets() {
80 let (app, state) = app_with("");
81 let hash = state.assets.hash().to_string();
82
83 let (status, headers, body) = get(&app, &format!("/-/static/{hash}/css/app.css")).await;
84 assert_eq!(status, StatusCode::OK);
85 assert_eq!(headers[header::CONTENT_TYPE], "text/css; charset=utf-8");
86 let expected = if cfg!(debug_assertions) {
87 "no-cache"
88 } else {
89 "public, max-age=31536000, immutable"
90 };
91 assert_eq!(headers[header::CACHE_CONTROL], expected);
92 assert!(body.contains("--text-display"));
93 assert!(body.contains("url(../fonts/space-mono-latin-400.woff2)"));
94
95 let (status, headers, _) = get(
96 &app,
97 &format!("/-/static/{hash}/fonts/space-mono-latin-400.woff2"),
98 )
99 .await;
100 assert_eq!(status, StatusCode::OK);
101 assert_eq!(headers[header::CONTENT_TYPE], "font/woff2");
102
103 // Stale hash: still served, never cached as immutable.
104 let (status, headers, _) = get(&app, "/-/static/000000000000/js/theme.js").await;
105 assert_eq!(status, StatusCode::OK);
106 assert_eq!(headers[header::CACHE_CONTROL], "no-cache");
107
108 let (status, _, _) = get(&app, &format!("/-/static/{hash}/nope.css")).await;
109 assert_eq!(status, StatusCode::NOT_FOUND);
110 let (status, _, _) = get(&app, &format!("/-/static/{hash}/../Cargo.toml")).await;
111 assert_eq!(status, StatusCode::NOT_FOUND);
112}
113
114#[tokio::test]
115async fn every_referenced_asset_exists() {
116 let (app, _) = app_with("");
117 let (_, _, page) = get(&app, "/").await;
118 let mut urls: Vec<String> = page
119 .split(['"', '\''])
120 .filter(|s| s.starts_with("/-/static/"))
121 .map(str::to_string)
122 .collect();
123 // Fonts referenced from the stylesheet resolve relative to css/, with
124 // `..` collapsed the way a browser does before requesting.
125 let css_url = urls
126 .iter()
127 .find(|u| u.ends_with("css/app.css"))
128 .unwrap()
129 .clone();
130 let (_, _, css) = get(&app, &css_url).await;
131 let static_root = css_url.trim_end_matches("css/app.css");
132 for part in css.split("url(").skip(1) {
133 let rel = part.split(')').next().unwrap();
134 let rel = rel.strip_prefix("../").expect("font urls are ../fonts/…");
135 urls.push(format!("{static_root}{rel}"));
136 }
137 assert!(urls.len() > 10, "{urls:?}");
138 for url in urls {
139 let (status, _, _) = get(&app, &url).await;
140 assert_eq!(status, StatusCode::OK, "{url}");
141 }
142}
143
144#[tokio::test]
145async fn not_found_page() {
146 let (app, _) = app_with("");
147 let (status, headers, body) = get(&app, "/no/such/thing").await;
148 assert_eq!(status, StatusCode::NOT_FOUND);
149 assert_eq!(headers[header::CONTENT_TYPE], "text/html; charset=utf-8");
150 assert!(body.contains(">404<"));
151 assert!(body.contains("[ERROR: PAGE NOT FOUND]"));
152}
153
154#[tokio::test]
155async fn base_path_prefixes_routes_and_links() {
156 let (app, state) = app_with("[server]\nbase_path = \"/git\"\n");
157 let (status, _, body) = get(&app, "/git/").await;
158 assert_eq!(status, StatusCode::OK);
159 assert!(body.contains(&format!(
160 "/git/-/static/{}/css/app.css",
161 state.assets.hash()
162 )));
163 assert!(body.contains(r#"href="/git/""#));
164
165 let (status, headers, _) = get(&app, "/git").await;
166 assert_eq!(status, StatusCode::PERMANENT_REDIRECT);
167 assert_eq!(headers[header::LOCATION], "/git/");
168
169 assert_eq!(get(&app, "/git/-/health").await.0, StatusCode::OK);
170 assert_eq!(get(&app, "/-/health").await.0, StatusCode::NOT_FOUND);
171 assert_eq!(get(&app, "/gitx/").await.0, StatusCode::NOT_FOUND);
172}
173
174#[tokio::test]
175async fn site_css_carries_code_font_and_custom_css() {
176 let (app, _) = app_with("");
177 let (_, _, body) = get(&app, "/").await;
178 assert!(!body.contains("/-/site.css"));
179
180 let (app, _) = app_with("[ui]\ncode_font = \"Berkeley Mono\"\n");
181 let (_, _, body) = get(&app, "/").await;
182 let href = body
183 .split('"')
184 .find(|s| s.starts_with("/-/site.css?v="))
185 .expect("site.css link");
186 let (status, _, css) = get(&app, href).await;
187 assert_eq!(status, StatusCode::OK);
188 assert!(css.contains("--font-code: \"Berkeley Mono\""));
189}
190
191#[tokio::test]
192async fn compression_skips_fonts() {
193 let (app, state) = app_with("");
194 let hash = state.assets.hash().to_string();
195 let req = |uri: String| {
196 Request::get(uri)
197 .header(header::ACCEPT_ENCODING, "br, gzip")
198 .body(Body::empty())
199 .unwrap()
200 };
201 let res = app
202 .clone()
203 .oneshot(req(format!("/-/static/{hash}/css/app.css")))
204 .await
205 .unwrap();
206 assert_eq!(res.headers()[header::CONTENT_ENCODING], "gzip");
207 let res = app
208 .clone()
209 .oneshot(req(format!(
210 "/-/static/{hash}/fonts/space-mono-latin-400.woff2"
211 )))
212 .await
213 .unwrap();
214 assert!(res.headers().get(header::CONTENT_ENCODING).is_none());
215}