added testing

This commit is contained in:
2026-05-16 23:48:57 +02:00
parent 23c62fb1e6
commit f1d5c4a4fd
16 changed files with 1014 additions and 119 deletions
+2
View File
@@ -0,0 +1,2 @@
edition = "2024"
max_width = 100
+5 -1
View File
@@ -7,6 +7,7 @@ use tracing::error;
use crate::models::ErrorResponse;
#[derive(Debug)]
pub enum AppError {
Unauthorized,
NotFound(String),
@@ -29,7 +30,10 @@ impl IntoResponse for AppError {
} else {
error!("Internal error: {}", msg);
}
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string())
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal error".to_string(),
)
}
};
+30 -10
View File
@@ -35,16 +35,36 @@ pub async fn update_config(
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_default();
if let Some(v) = patch.title { config.title = v; }
if let Some(v) = patch.subtitle { config.subtitle = v; }
if let Some(v) = patch.welcome_title { config.welcome_title = v; }
if let Some(v) = patch.welcome_subtitle { config.welcome_subtitle = v; }
if let Some(v) = patch.footer { config.footer = v; }
if let Some(v) = patch.favicon { config.favicon = v; }
if let Some(v) = patch.theme { config.theme = v; }
if let Some(v) = patch.custom_css { config.custom_css = v; }
if let Some(v) = patch.contact_intro { config.contact_intro = v; }
if let Some(v) = patch.contact_links { config.contact_links = v; }
if let Some(v) = patch.title {
config.title = v;
}
if let Some(v) = patch.subtitle {
config.subtitle = v;
}
if let Some(v) = patch.welcome_title {
config.welcome_title = v;
}
if let Some(v) = patch.welcome_subtitle {
config.welcome_subtitle = v;
}
if let Some(v) = patch.footer {
config.footer = v;
}
if let Some(v) = patch.favicon {
config.favicon = v;
}
if let Some(v) = patch.theme {
config.theme = v;
}
if let Some(v) = patch.custom_css {
config.custom_css = v;
}
if let Some(v) = patch.contact_intro {
config.contact_intro = v;
}
if let Some(v) = patch.contact_links {
config.contact_links = v;
}
let config_str = serde_json::to_string_pretty(&config).map_err(|e| {
error!("Serialization error: {}", e);
+10 -9
View File
@@ -123,7 +123,10 @@ pub async fn submit_contact(
if name.as_ref().is_some_and(|s| s.chars().count() > MAX_NAME) {
return Err(AppError::BadRequest("Name is too long.".into()));
}
if email.as_ref().is_some_and(|s| s.chars().count() > MAX_EMAIL) {
if email
.as_ref()
.is_some_and(|s| s.chars().count() > MAX_EMAIL)
{
return Err(AppError::BadRequest("Email is too long.".into()));
}
if subject
@@ -160,9 +163,8 @@ pub async fn submit_contact(
AppError::Internal("Storage error".into(), Some(e.to_string()))
})?;
let path = messages_dir.join(format!("{}.json", id));
let json = serde_json::to_string_pretty(&msg).map_err(|e| {
AppError::Internal("Serialization error".into(), Some(e.to_string()))
})?;
let json = serde_json::to_string_pretty(&msg)
.map_err(|e| AppError::Internal("Serialization error".into(), Some(e.to_string())))?;
fs::write(&path, json).await.map_err(|e| {
error!("Failed to write message {}: {}", id, e);
AppError::Internal("Storage error".into(), Some(e.to_string()))
@@ -189,8 +191,7 @@ pub async fn list_messages(
Ok(e) => e,
Err(e) => {
error!("Failed to read messages dir: {}", e);
return AppError::Internal("Read error".into(), Some(e.to_string()))
.into_response();
return AppError::Internal("Read error".into(), Some(e.to_string())).into_response();
}
};
@@ -239,8 +240,8 @@ pub async fn delete_message(
if !fs::try_exists(&path).await.unwrap_or(false) {
return Err(AppError::NotFound("Message not found.".into()));
}
fs::remove_file(&path).await.map_err(|e| {
AppError::Internal("Delete failed".into(), Some(e.to_string()))
})?;
fs::remove_file(&path)
.await
.map_err(|e| AppError::Internal("Delete failed".into(), Some(e.to_string())))?;
Ok(Json(ContactResponse { ok: true }))
}
+129 -41
View File
@@ -21,9 +21,8 @@ const WORDS_PER_MINUTE: u32 = 200;
const MAX_SLUG_LEN: usize = 100;
const WINDOWS_RESERVED: &[&str] = &[
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
fn validate_slug(s: &str) -> Result<(), AppError> {
@@ -47,9 +46,7 @@ fn validate_slug(s: &str) -> Result<(), AppError> {
));
}
if s.contains("..") {
return Err(AppError::BadRequest(
"Slug cannot contain '..'".to_string(),
));
return Err(AppError::BadRequest("Slug cannot contain '..'".to_string()));
}
for c in s.chars() {
if c.is_control() {
@@ -66,15 +63,15 @@ fn validate_slug(s: &str) -> Result<(), AppError> {
}
let stem = s.split('.').next().unwrap_or("").to_ascii_uppercase();
if WINDOWS_RESERVED.iter().any(|r| *r == stem) {
return Err(AppError::BadRequest(
"Slug is a reserved name".to_string(),
));
return Err(AppError::BadRequest("Slug is a reserved name".to_string()));
}
Ok(())
}
fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
let raw = raw.strip_prefix("---\n").or_else(|| raw.strip_prefix("---\r\n"))?;
let raw = raw
.strip_prefix("---\n")
.or_else(|| raw.strip_prefix("---\r\n"))?;
let end_marker = raw.find("\n---\n").or_else(|| raw.find("\r\n---\r\n"))?;
let yaml = &raw[..end_marker];
let body_start = end_marker
@@ -82,7 +79,9 @@ fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
.find("---\n")
.or_else(|| raw[end_marker..].find("---\r\n"))?
+ "---\n".len();
let body = raw[body_start..].trim_start_matches('\n').trim_start_matches('\r');
let body = raw[body_start..]
.trim_start_matches('\n')
.trim_start_matches('\r');
Some((yaml, body))
}
@@ -103,12 +102,8 @@ fn parse_post(raw: &str) -> Result<(PostMeta, String), AppError> {
}
fn serialize_post(meta: &PostMeta, body: &str) -> Result<String, AppError> {
let yaml = serde_yaml::to_string(meta).map_err(|e| {
AppError::Internal(
"Serialization error".to_string(),
Some(e.to_string()),
)
})?;
let yaml = serde_yaml::to_string(meta)
.map_err(|e| AppError::Internal("Serialization error".to_string(), Some(e.to_string())))?;
Ok(format!("---\n{}---\n{}", yaml, body))
}
@@ -176,11 +171,7 @@ fn cover_from(images: &[(String, String)]) -> Option<CoverImage> {
/// bytes via `imagesize::size`, off the runtime via `spawn_blocking`.
async fn compute_dim_from_url(state: &AppState, url: &str) -> Option<ImageDim> {
let name = url.strip_prefix("/uploads/")?;
if name.is_empty()
|| name.contains("..")
|| name.contains('\\')
|| name.starts_with('/')
{
if name.is_empty() || name.contains("..") || name.contains('\\') || name.starts_with('/') {
return None;
}
let path = state.data_dir.join("uploads").join(name);
@@ -254,9 +245,7 @@ fn excerpt_from(meta: &PostMeta, body: &str) -> String {
return s.trim().to_string();
}
}
let plain = body
.replace(['#', '*', '_', '`'], "")
.replace('\n', " ");
let plain = body.replace(['#', '*', '_', '`'], "").replace('\n', " ");
let mut out: String = plain.chars().take(200).collect();
if plain.chars().count() > 200 {
out.push_str("...");
@@ -340,17 +329,16 @@ pub async fn rebuild_posts_cache(state: &AppState) {
*state.posts_cache.write().await = posts;
}
async fn write_post_atomic(
state: &AppState,
slug: &str,
contents: &str,
) -> Result<(), AppError> {
async fn write_post_atomic(state: &AppState, slug: &str, contents: &str) -> Result<(), AppError> {
let _guard = state.post_lock.lock().await;
let final_path = state.data_dir.join("posts").join(format!("{}.md", slug));
let tmp_path = state.data_dir.join("posts").join(format!(".{}.md.tmp", slug));
fs::write(&tmp_path, contents).await.map_err(|e| {
AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?;
let tmp_path = state
.data_dir
.join("posts")
.join(format!(".{}.md.tmp", slug));
fs::write(&tmp_path, contents)
.await
.map_err(|e| AppError::Internal("Write error".to_string(), Some(e.to_string())))?;
if let Err(e) = fs::rename(&tmp_path, &final_path).await {
let _ = fs::remove_file(&tmp_path).await;
return Err(AppError::Internal(
@@ -407,13 +395,17 @@ pub async fn create_post(
let images = extract_images(&payload.content);
if images.is_empty() {
return Err(AppError::BadRequest(
"A gallery entry must include at least one image (![](url) in the markdown body).".to_string(),
"A gallery entry must include at least one image (![](url) in the markdown body)."
.to_string(),
));
}
let meta = PostMeta {
date: payload.date.unwrap_or_else(|| Utc::now().date_naive()),
title: payload.title.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
title: payload
.title
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty()),
summary: payload.summary.filter(|s| !s.trim().is_empty()),
tags: payload.tags,
draft: payload.draft,
@@ -515,7 +507,11 @@ async fn neighbors_from_cache(
slug: p.slug.clone(),
title: p.title.clone(),
};
let prev = if i > 0 { Some(to_neighbor(visible[i - 1])) } else { None };
let prev = if i > 0 {
Some(to_neighbor(visible[i - 1]))
} else {
None
};
let next = visible.get(i + 1).map(|p| to_neighbor(p));
(prev, next)
}
@@ -541,10 +537,7 @@ pub async fn get_post(
let (prev, next) = neighbors_from_cache(&state, &slug, admin).await;
let image_urls: Vec<String> = extract_images(&body)
.into_iter()
.map(|(_, u)| u)
.collect();
let image_urls: Vec<String> = extract_images(&body).into_iter().map(|(_, u)| u).collect();
let dimensions = dims_for_urls(&state, &image_urls).await;
Ok(Json(PostDetail {
@@ -563,3 +556,98 @@ pub async fn get_post(
dimensions,
}))
}
#[cfg(test)]
mod tests {
use super::{
cover_from, extract_images, parse_post, reading_time, split_frontmatter, validate_slug,
};
use crate::error::AppError;
#[test]
fn validate_slug_accepts_normal_slugs() {
assert!(validate_slug("hello-world").is_ok());
assert!(validate_slug("a_b.c-123").is_ok());
}
#[test]
fn validate_slug_rejects_traversal_and_bad_chars() {
for bad in [
"",
"../etc",
"with/slash",
"back\\slash",
"ends.",
"trailing ",
".hidden",
] {
assert!(
matches!(validate_slug(bad), Err(AppError::BadRequest(_))),
"expected {bad:?} to be rejected"
);
}
let too_long = "x".repeat(101);
assert!(validate_slug(&too_long).is_err());
assert!(matches!(validate_slug("CON"), Err(AppError::BadRequest(_))));
}
#[test]
fn split_frontmatter_handles_lf_and_crlf() {
let (yaml, body) = split_frontmatter("---\ndate: 2026-05-16\n---\nHello").unwrap();
assert_eq!(yaml, "date: 2026-05-16");
assert_eq!(body, "Hello");
let (y2, b2) = split_frontmatter("---\r\ndate: 2026-05-16\r\n---\r\nHi").unwrap();
assert!(y2.contains("date: 2026-05-16"));
assert_eq!(b2, "Hi");
assert!(split_frontmatter("no frontmatter here").is_none());
}
#[test]
fn parse_post_reads_meta_and_body() {
let raw = "---\ndate: 2026-05-16\ntitle: Hello\ndraft: true\n---\nBody text";
let (meta, body) = parse_post(raw).unwrap();
assert_eq!(meta.title.as_deref(), Some("Hello"));
assert!(meta.draft);
assert_eq!(meta.date.to_string(), "2026-05-16");
assert_eq!(body, "Body text");
assert!(parse_post("no frontmatter").is_err());
}
#[test]
fn reading_time_rounds_up_by_wpm() {
assert_eq!(reading_time(""), 0);
assert_eq!(reading_time("one"), 1);
assert_eq!(reading_time(&"word ".repeat(200)), 1);
assert_eq!(reading_time(&"word ".repeat(201)), 2);
}
#[test]
fn extract_images_skips_fences_and_strips_titles() {
let md = "intro\n\
![a](/u/one.png)\n\
```\n\
![skip](/u/hidden.png)\n\
```\n\
![c](/u/two.png \"a title\")";
let imgs = extract_images(md);
assert_eq!(
imgs,
vec![
("a".to_string(), "/u/one.png".to_string()),
("c".to_string(), "/u/two.png".to_string()),
]
);
}
#[test]
fn cover_from_takes_first_or_none() {
assert!(cover_from(&[]).is_none());
let imgs = vec![("alt".to_string(), "/u/first.png".to_string())];
let cover = cover_from(&imgs).unwrap();
assert_eq!(cover.url, "/u/first.png");
assert_eq!(cover.alt, "alt");
}
}
+8 -10
View File
@@ -23,10 +23,8 @@ pub struct UploadQuery {
/// Allowed upload extensions. SVG, HTML, JS, executables intentionally absent —
/// /uploads/* is served as-is, so any active content there is XSS waiting to happen.
const ALLOWED_EXTS: &[&str] = &[
"jpg", "jpeg", "png", "webp", "gif", "avif",
"pdf", "txt", "md",
"mp3", "wav", "ogg",
"mp4", "webm", "mov",
"jpg", "jpeg", "png", "webp", "gif", "avif", "pdf", "txt", "md", "mp3", "wav", "ogg", "mp4",
"webm", "mov",
];
fn validate_filename(name: &str) -> Result<(), AppError> {
@@ -77,9 +75,9 @@ pub async fn delete_upload(
let uploads_dir = state.data_dir.join("uploads");
let file_path = uploads_dir.join(&filename);
let canonical_dir = fs::canonicalize(&uploads_dir).await.map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
})?;
let canonical_dir = fs::canonicalize(&uploads_dir)
.await
.map_err(|e| AppError::Internal("Path resolution".to_string(), Some(e.to_string())))?;
if let Ok(canonical_file) = fs::canonicalize(&file_path).await {
if !canonical_file.starts_with(&canonical_dir) {
warn!("Refused delete outside uploads dir: {}", filename);
@@ -209,9 +207,9 @@ pub async fn upload_file(
};
// Final containment check.
let canonical_dir = fs::canonicalize(&uploads_dir).await.map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
})?;
let canonical_dir = fs::canonicalize(&uploads_dir)
.await
.map_err(|e| AppError::Internal("Path resolution".to_string(), Some(e.to_string())))?;
if let Some(parent) = final_path.parent() {
let canonical_parent = fs::canonicalize(parent).await.map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
+8 -8
View File
@@ -47,9 +47,7 @@ async fn main() {
.filter(|t| !t.trim().is_empty())
.expect("ADMIN_TOKEN must be set to a non-empty value");
if admin_token.len() < 16 {
warn!(
"ADMIN_TOKEN is shorter than 16 characters. Use a long random string in production."
);
warn!("ADMIN_TOKEN is shorter than 16 characters. Use a long random string in production.");
}
let data_dir_str = env::var("DATA_DIR").unwrap_or_else(|_| "../data".to_string());
let data_dir = PathBuf::from(data_dir_str);
@@ -79,7 +77,10 @@ async fn main() {
});
handlers::posts::rebuild_posts_cache(&state).await;
info!("Posts cache primed with {} entries", state.posts_cache.read().await.len());
info!(
"Posts cache primed with {} entries",
state.posts_cache.read().await.len()
);
spawn_rate_limit_reaper(state.clone());
@@ -89,8 +90,8 @@ async fn main() {
// server-to-server and not subject to CORS.
let cors = match env::var("FRONTEND_ORIGIN").ok().filter(|s| !s.is_empty()) {
Some(origin) => {
let value = HeaderValue::from_str(&origin)
.expect("FRONTEND_ORIGIN must be a valid origin URL");
let value =
HeaderValue::from_str(&origin).expect("FRONTEND_ORIGIN must be a valid origin URL");
CorsLayer::new()
.allow_origin(AllowOrigin::exact(value))
.allow_methods([
@@ -132,8 +133,7 @@ async fn main() {
)
.route(
"/api/upload",
post(handlers::upload::upload_file)
.layer(DefaultBodyLimit::max(UPLOAD_BODY_LIMIT)),
post(handlers::upload::upload_file).layer(DefaultBodyLimit::max(UPLOAD_BODY_LIMIT)),
)
.route("/api/contact", post(handlers::contact::submit_contact))
.route("/api/messages", get(handlers::contact::list_messages))
+2 -3
View File
@@ -31,9 +31,8 @@ impl Default for SiteConfig {
title: "Ela's Atelier".to_string(),
subtitle: "Works on paper, canvas, and elsewhere".to_string(),
welcome_title: "Works on view".to_string(),
welcome_subtitle:
"An ongoing arrangement of pieces, sketches, and stray observations."
.to_string(),
welcome_subtitle: "An ongoing arrangement of pieces, sketches, and stray observations."
.to_string(),
footer: "Hand-arranged with care".to_string(),
favicon: "/favicon.svg".to_string(),
theme: "salon".to_string(),