mod common;
use axum::http::StatusCode;
use common::*;
use what_core::server::create_router;
#[tokio::test]
async fn upload_creates_record_with_file_path() {
let proj = TestProject::new();
proj.add_page("index.html", r##"<h1>#flash.success|default:"none"#</h1>"##);
let (_dir, state) = proj.build_state_with_uploads();
let router = create_router(state);
let resp = post_multipart(
&router,
"/w-upload/photos?w-redirect=/",
vec![
("title", MultipartField::Text("My Photo")),
(
"file",
MultipartField::File {
filename: "test.png",
content_type: "image/png",
data: b"fake-png-data",
},
),
],
)
.await;
assert_eq!(resp.status, StatusCode::SEE_OTHER);
assert_eq!(resp.location(), Some("/"));
let cookie = resp.set_cookie().expect("should set session cookie");
let resp = get_with_headers(&router, "/", vec![("cookie", cookie)]).await;
resp.assert_contains("Item created in photos");
}
#[tokio::test]
async fn upload_disabled_returns_error_flash() {
let proj = TestProject::new();
proj.add_page("form.html", r##"<p>#flash.error|default:"no-error"#</p>"##);
let (_dir, state) = proj.build_state();
let router = create_router(state);
let resp = post_multipart_with_headers(
&router,
"/w-upload/photos?w-redirect=/form",
vec![(
"file",
MultipartField::File {
filename: "test.png",
content_type: "image/png",
data: b"fake-png-data",
},
)],
vec![("referer", "/form")],
)
.await;
assert_eq!(resp.status, StatusCode::SEE_OTHER);
let cookie = resp.set_cookie().expect("should set session cookie");
let resp = get_with_headers(&router, "/form", vec![("cookie", cookie)]).await;
resp.assert_contains("not enabled");
}
#[tokio::test]
async fn delete_action_cannot_traverse_out_of_uploads_dir() {
let proj = TestProject::new();
proj.add_page("index.html", r##"<form w-validate action="/x"></form><h1>ok</h1>"##);
let (dir, state) = proj.build_state_with_uploads();
let target = dir.path().join("SECRET_TARGET.txt");
std::fs::write(&target, "do-not-delete").unwrap();
let router = create_router(state);
let created = post_form(
&router,
"/w-action/notes?w-redirect=/",
"evil=/uploads/../SECRET_TARGET.txt",
)
.await;
let cookie = created.session_cookie().expect("session cookie");
let page = get_with_headers(&router, "/", vec![("cookie", &cookie)]).await;
let csrf = page.csrf_token().expect("csrf token in page");
let del = post_form_with_headers(
&router,
"/w-action/notes/1?w-action=delete&w-redirect=/",
"",
vec![("cookie", &cookie), ("x-csrf-token", &csrf)],
)
.await;
assert_eq!(del.status, StatusCode::SEE_OTHER);
assert!(
target.exists(),
"path traversal in cleanup deleted a file outside uploads/"
);
}
#[tokio::test]
async fn uploaded_html_served_as_attachment_not_executed() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>ok</h1>");
let (dir, state) = proj.build_state_with_uploads();
let router = create_router(state);
post_multipart(
&router,
"/w-upload/files?w-redirect=/",
vec![(
"file",
MultipartField::File {
filename: "evil.html",
content_type: "text/html",
data: b"<script>alert(document.domain)</script>",
},
)],
)
.await;
let uploads_dir = dir.path().join("uploads");
let name = std::fs::read_dir(&uploads_dir)
.unwrap()
.filter_map(|e| e.ok())
.next()
.unwrap()
.file_name();
let resp = get(&router, &format!("/uploads/{}", name.to_str().unwrap())).await;
assert_eq!(resp.status, StatusCode::OK);
assert_eq!(
resp.header("content-disposition"),
Some("attachment"),
"uploaded HTML must be forced to download"
);
}
#[tokio::test]
async fn uploaded_image_renders_inline() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>ok</h1>");
let (dir, state) = proj.build_state_with_uploads();
let router = create_router(state);
post_multipart(
&router,
"/w-upload/photos?w-redirect=/",
vec![(
"file",
MultipartField::File {
filename: "pic.png",
content_type: "image/png",
data: b"\x89PNG\r\n\x1a\n fake",
},
)],
)
.await;
let uploads_dir = dir.path().join("uploads");
let name = std::fs::read_dir(&uploads_dir)
.unwrap()
.filter_map(|e| e.ok())
.next()
.unwrap()
.file_name();
let resp = get(&router, &format!("/uploads/{}", name.to_str().unwrap())).await;
assert_eq!(resp.status, StatusCode::OK);
assert!(
resp.header("content-disposition").is_none(),
"images should render inline, not download"
);
}
#[tokio::test]
async fn upload_saves_file_to_uploads_dir() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>ok</h1>");
let (dir, state) = proj.build_state_with_uploads();
let router = create_router(state);
post_multipart(
&router,
"/w-upload/files?w-redirect=/",
vec![(
"doc",
MultipartField::File {
filename: "readme.txt",
content_type: "text/plain",
data: b"Hello, world!",
},
)],
)
.await;
let uploads_dir = dir.path().join("uploads");
let entries: Vec<_> = std::fs::read_dir(&uploads_dir)
.expect("uploads dir should exist")
.filter_map(|e| e.ok())
.collect();
assert_eq!(entries.len(), 1, "Should have exactly one uploaded file");
let saved_path = entries[0].path();
let content = std::fs::read_to_string(&saved_path).unwrap();
assert_eq!(content, "Hello, world!");
let fname = saved_path.file_name().unwrap().to_str().unwrap();
assert!(
fname.ends_with(".txt"),
"Should preserve .txt extension, got: {}",
fname
);
assert!(fname.len() > 10, "Should have UUID prefix");
}
#[tokio::test]
async fn upload_file_served_at_uploads_path() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>ok</h1>");
let (dir, state) = proj.build_state_with_uploads();
let router = create_router(state);
post_multipart(
&router,
"/w-upload/docs?w-redirect=/",
vec![(
"file",
MultipartField::File {
filename: "hello.txt",
content_type: "text/plain",
data: b"file-content-here",
},
)],
)
.await;
let uploads_dir = dir.path().join("uploads");
let entries: Vec<_> = std::fs::read_dir(&uploads_dir)
.unwrap()
.filter_map(|e| e.ok())
.collect();
let saved_name = entries[0].file_name();
let resp = get(
&router,
&format!("/uploads/{}", saved_name.to_str().unwrap()),
)
.await;
assert_eq!(resp.status, StatusCode::OK);
assert_eq!(resp.body, "file-content-here");
}
#[tokio::test]
async fn upload_rejects_oversized_file() {
let proj = TestProject::new();
proj.add_page("form.html", r##"<p>#flash.error|default:"no-error"#</p>"##);
let mut config = what_core::Config::default();
config.uploads.enabled = true;
config.uploads.max_size = "100".to_string(); let (_dir, state) = proj.build_state_with_config(config);
let router = create_router(state);
let big_data = vec![0u8; 200];
let resp = post_multipart_with_headers(
&router,
"/w-upload/files?w-redirect=/form",
vec![(
"file",
MultipartField::File {
filename: "big.bin",
content_type: "application/octet-stream",
data: &big_data,
},
)],
vec![("referer", "/form")],
)
.await;
assert_eq!(resp.status, StatusCode::SEE_OTHER);
let cookie = resp.set_cookie().expect("should set session cookie");
let resp = get_with_headers(&router, "/form", vec![("cookie", cookie)]).await;
resp.assert_contains("exceeds maximum size");
}
#[tokio::test]
async fn upload_rejects_disallowed_type() {
let proj = TestProject::new();
proj.add_page("form.html", r##"<p>#flash.error|default:"no-error"#</p>"##);
let mut config = what_core::Config::default();
config.uploads.enabled = true;
config.uploads.allowed_types = vec!["image/*".to_string()];
let (_dir, state) = proj.build_state_with_config(config);
let router = create_router(state);
let resp = post_multipart_with_headers(
&router,
"/w-upload/files?w-redirect=/form",
vec![(
"file",
MultipartField::File {
filename: "script.js",
content_type: "application/javascript",
data: b"alert('hi')",
},
)],
vec![("referer", "/form")],
)
.await;
assert_eq!(resp.status, StatusCode::SEE_OTHER);
let cookie = resp.set_cookie().expect("should set session cookie");
let resp = get_with_headers(&router, "/form", vec![("cookie", cookie)]).await;
resp.assert_contains("not allowed");
}
#[tokio::test]
async fn upload_allows_matching_type() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>ok</h1>");
let mut config = what_core::Config::default();
config.uploads.enabled = true;
config.uploads.allowed_types = vec!["image/*".to_string()];
let (_dir, state) = proj.build_state_with_config(config);
let router = create_router(state);
let resp = post_multipart(
&router,
"/w-upload/photos?w-redirect=/",
vec![(
"file",
MultipartField::File {
filename: "photo.jpg",
content_type: "image/jpeg",
data: b"fake-jpeg",
},
)],
)
.await;
assert_eq!(resp.status, StatusCode::SEE_OTHER);
assert_eq!(resp.location(), Some("/"));
}
#[tokio::test]
async fn upload_with_multiple_files() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>ok</h1>");
let (dir, state) = proj.build_state_with_uploads();
let router = create_router(state);
post_multipart(
&router,
"/w-upload/gallery?w-redirect=/",
vec![
("title", MultipartField::Text("My Gallery")),
(
"photo1",
MultipartField::File {
filename: "a.png",
content_type: "image/png",
data: b"data-a",
},
),
(
"photo2",
MultipartField::File {
filename: "b.jpg",
content_type: "image/jpeg",
data: b"data-b",
},
),
],
)
.await;
let uploads_dir = dir.path().join("uploads");
let entries: Vec<_> = std::fs::read_dir(&uploads_dir)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(entries.len(), 2, "Should have two uploaded files");
}
#[tokio::test]
async fn upload_empty_file_field_skipped() {
let proj = TestProject::new();
proj.add_page("index.html", "<h1>ok</h1>");
let (dir, state) = proj.build_state_with_uploads();
let router = create_router(state);
let resp = post_multipart(
&router,
"/w-upload/items?w-redirect=/",
vec![
("title", MultipartField::Text("No file")),
(
"file",
MultipartField::File {
filename: "",
content_type: "application/octet-stream",
data: b"",
},
),
],
)
.await;
assert_eq!(resp.status, StatusCode::SEE_OTHER);
let uploads_dir = dir.path().join("uploads");
let entries: Vec<_> = std::fs::read_dir(&uploads_dir)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(entries.len(), 0, "Empty file fields should be skipped");
}