Krolyxon
Branch master

ferreniumtestshttp.rs

310 lines · 11.1 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 site_favicon_logo_and_accent() {
193 let (_, _, body) = get(&app_with("").0, "/").await;
194 assert!(body.contains("icons/favicon.svg\" type=\"image/svg+xml\""));
195 assert!(!body.contains("topbar-logo"));
196
197 let dir = tempfile::tempdir().unwrap();
198 std::fs::write(dir.path().join("icon.ico"), b"ICO").unwrap();
199 std::fs::write(dir.path().join("logo.svg"), b"<svg/>").unwrap();
200 let (app, _) = app_with(&format!(
201 "[site]\nfavicon = {:?}\nlogo = {:?}\n[ui]\naccent = \"#0a0\"\n",
202 dir.path().join("icon.ico"),
203 dir.path().join("logo.svg"),
204 ));
205 let (_, _, body) = get(&app, "/").await;
206 let link = |prefix: &str| {
207 body.split('"')
208 .find(|s| s.starts_with(prefix))
209 .unwrap_or_else(|| panic!("no {prefix} link in page"))
210 .to_string()
211 };
212
213 assert!(body.contains("type=\"image/x-icon\""));
214 let (status, headers, icon) = get(&app, &link("/-/favicon?v=")).await;
215 assert_eq!(status, StatusCode::OK);
216 assert_eq!(headers[header::CONTENT_TYPE], "image/x-icon");
217 assert_eq!(icon, "ICO");
218
219 assert!(body.contains("class=\"topbar-logo\""));
220 let (status, headers, logo) = get(&app, &link("/-/logo?v=")).await;
221 assert_eq!(status, StatusCode::OK);
222 assert_eq!(headers[header::CONTENT_TYPE], "image/svg+xml");
223 assert!(headers.contains_key(header::CONTENT_SECURITY_POLICY));
224 assert_eq!(logo, "<svg/>");
225
226 let (_, _, css) = get(&app, &link("/-/site.css?v=")).await;
227 assert!(css.contains("--accent: #0a0; --accent-subtle: rgba(0, 170, 0, 0.15);"));
228
229 let (app, _) = app_with("");
230 assert_eq!(get(&app, "/-/favicon").await.0, StatusCode::NOT_FOUND);
231 assert_eq!(get(&app, "/-/logo").await.0, StatusCode::NOT_FOUND);
232}
233
234#[tokio::test]
235async fn site_assets_dir() {
236 let root = tempfile::tempdir().unwrap();
237 let assets = root.path().join("assets");
238 std::fs::create_dir_all(assets.join("img")).unwrap();
239 std::fs::write(assets.join("img/bg.png"), b"PNG").unwrap();
240 std::fs::write(assets.join(".hidden.png"), b"x").unwrap();
241 std::fs::write(assets.join("page.html"), b"<p>").unwrap();
242 std::fs::write(root.path().join("secret.png"), b"secret").unwrap();
243 std::os::unix::fs::symlink(root.path().join("secret.png"), assets.join("escape.png")).unwrap();
244 std::os::unix::fs::symlink(assets.join("img/bg.png"), assets.join("alias.png")).unwrap();
245
246 let (app, _) = app_with(&format!("[site]\nassets_dir = {assets:?}\n"));
247 let (status, headers, body) = get(&app, "/-/site/img/bg.png").await;
248 assert_eq!(status, StatusCode::OK);
249 assert_eq!(headers[header::CONTENT_TYPE], "image/png");
250 assert_eq!(headers[header::CACHE_CONTROL], "no-cache");
251 assert_eq!(body, "PNG");
252 assert_eq!(get(&app, "/-/site/alias.png").await.2, "PNG");
253
254 let etag = headers[header::ETAG].clone();
255 let res = app
256 .clone()
257 .oneshot(
258 Request::get("/-/site/img/bg.png")
259 .header(header::IF_NONE_MATCH, etag)
260 .body(Body::empty())
261 .unwrap(),
262 )
263 .await
264 .unwrap();
265 assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
266
267 for path in [
268 "/-/site/escape.png",
269 "/-/site/.hidden.png",
270 "/-/site/page.html",
271 "/-/site/img/../../secret.png",
272 "/-/site/img/%2e%2e/%2e%2e/secret.png",
273 "/-/site/missing.png",
274 "/-/site/img",
275 ] {
276 assert_eq!(get(&app, path).await.0, StatusCode::NOT_FOUND, "{path}");
277 }
278
279 let (app, _) = app_with("");
280 assert_eq!(
281 get(&app, "/-/site/img/bg.png").await.0,
282 StatusCode::NOT_FOUND
283 );
284}
285
286#[tokio::test]
287async fn compression_skips_fonts() {
288 let (app, state) = app_with("");
289 let hash = state.assets.hash().to_string();
290 let req = |uri: String| {
291 Request::get(uri)
292 .header(header::ACCEPT_ENCODING, "br, gzip")
293 .body(Body::empty())
294 .unwrap()
295 };
296 let res = app
297 .clone()
298 .oneshot(req(format!("/-/static/{hash}/css/app.css")))
299 .await
300 .unwrap();
301 assert_eq!(res.headers()[header::CONTENT_ENCODING], "gzip");
302 let res = app
303 .clone()
304 .oneshot(req(format!(
305 "/-/static/{hash}/fonts/space-mono-latin-400.woff2"
306 )))
307 .await
308 .unwrap();
309 assert!(res.headers().get(header::CONTENT_ENCODING).is_none());
310}