Files
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

954 lines
32 KiB
Rust

//! E2E tests for file operations (/api/fs/*).
mod common;
use axum::http::StatusCode;
use serde_json::json;
use tower::ServiceExt;
use common::{body_json, build_app, build_app_with_file_roots, json_with_token, setup_and_login};
// ===========================================================================
// Auth guard
// ===========================================================================
#[tokio::test]
async fn fs_endpoints_require_auth() {
let (app, _services) = build_app().await;
let endpoints = [
"/api/fs/dir",
"/api/fs/list",
"/api/fs/metadata",
"/api/fs/read",
"/api/fs/write",
"/api/fs/copy",
"/api/fs/remove",
"/api/fs/rename",
"/api/fs/temp",
"/api/fs/upload",
"/api/fs/image-base64",
"/api/fs/fetch-remote-image",
"/api/fs/zip",
"/api/fs/zip/cancel",
"/api/fs/watch/start",
"/api/fs/watch/stop",
"/api/fs/watch/stop-all",
"/api/fs/office-watch/start",
"/api/fs/office-watch/stop",
"/api/fs/snapshot/init",
"/api/fs/snapshot/info",
"/api/fs/snapshot/compare",
"/api/fs/snapshot/baseline",
"/api/fs/snapshot/stage",
"/api/fs/snapshot/stage-all",
"/api/fs/snapshot/unstage",
"/api/fs/snapshot/unstage-all",
"/api/fs/snapshot/discard",
"/api/fs/snapshot/reset",
"/api/fs/snapshot/branches",
"/api/fs/snapshot/dispose",
];
for uri in endpoints {
let req = axum::http::Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(axum::body::Body::from(r#"{}"#))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"expected 403 for unauthenticated {uri}"
);
}
}
// ===========================================================================
// Directory browsing
// ===========================================================================
#[tokio::test]
async fn get_files_by_dir_returns_directory_contents() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("hello.txt"), "world").unwrap();
std::fs::create_dir(root.join("subdir")).unwrap();
let req = json_with_token(
"POST",
"/api/fs/dir",
json!({
"dir": root.to_str().unwrap(),
"root": root.to_str().unwrap()
}),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
let data = json["data"].as_array().unwrap();
assert!(data.len() >= 2, "should contain file + subdir");
let names: Vec<&str> = data.iter().filter_map(|e| e["name"].as_str()).collect();
assert!(names.contains(&"hello.txt"));
assert!(names.contains(&"subdir"));
// Check directory has isDir=true
let subdir_entry = data.iter().find(|e| e["name"] == "subdir").unwrap();
assert_eq!(subdir_entry["is_dir"], true);
assert_eq!(subdir_entry["is_file"], false);
// Check file has isFile=true
let file_entry = data.iter().find(|e| e["name"] == "hello.txt").unwrap();
assert_eq!(file_entry["is_dir"], false);
assert_eq!(file_entry["is_file"], true);
}
#[tokio::test]
async fn list_workspace_files_flat_list() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("a.txt"), "a").unwrap();
std::fs::create_dir(root.join("nested")).unwrap();
std::fs::write(root.join("nested").join("b.txt"), "b").unwrap();
let req = json_with_token(
"POST",
"/api/fs/list",
json!({ "root": root.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let data = json["data"].as_array().unwrap();
assert!(data.len() >= 2, "should contain at least 2 files");
let names: Vec<&str> = data.iter().filter_map(|e| e["name"].as_str()).collect();
assert!(names.contains(&"a.txt"));
assert!(names.contains(&"b.txt"));
}
#[tokio::test]
async fn get_file_metadata_returns_info() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.txt");
std::fs::write(&file_path, "hello world").unwrap();
let req = json_with_token(
"POST",
"/api/fs/metadata",
json!({ "path": file_path.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["name"], "test.txt");
assert_eq!(json["data"]["size"], 11); // "hello world" = 11 bytes
assert!(json["data"]["last_modified"].as_i64().unwrap() > 0);
}
// ===========================================================================
// File read/write
// ===========================================================================
#[tokio::test]
async fn read_file_returns_content() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("read_me.txt");
std::fs::write(&file_path, "file content here").unwrap();
let req = json_with_token(
"POST",
"/api/fs/read",
json!({ "path": file_path.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"], "file content here");
}
#[tokio::test]
async fn read_file_nonexistent_returns_null() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let dir = tempfile::tempdir().unwrap();
let fake_path = dir.path().join("nonexistent.txt");
let req = json_with_token(
"POST",
"/api/fs/read",
json!({ "path": fake_path.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert!(json["data"].is_null());
}
#[tokio::test]
async fn read_file_with_workspace_field_accepts_non_home_path() {
let sandbox = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let (mut app, services) = build_app_with_file_roots(vec![sandbox.path().to_path_buf()]).await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let file_path = workspace.path().join("preview.md");
std::fs::write(&file_path, "# hello").unwrap();
let req = json_with_token(
"POST",
"/api/fs/read",
json!({
"path": file_path.to_str().unwrap(),
"workspace": workspace.path().to_str().unwrap()
}),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"], "# hello");
}
#[tokio::test]
async fn read_file_without_workspace_rejects_non_sandbox_path() {
let sandbox = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let (mut app, services) = build_app_with_file_roots(vec![sandbox.path().to_path_buf()]).await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let file_path = workspace.path().join("preview.md");
std::fs::write(&file_path, "# hello").unwrap();
let req = json_with_token(
"POST",
"/api/fs/read",
json!({ "path": file_path.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let json = body_json(resp).await;
assert_eq!(json["code"], "PATH_OUTSIDE_SANDBOX");
}
#[tokio::test]
async fn read_file_non_existent_within_sandbox_returns_null() {
let sandbox = tempfile::tempdir().unwrap();
let (mut app, services) = build_app_with_file_roots(vec![sandbox.path().to_path_buf()]).await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let file_path = sandbox.path().join("missing.md");
let req = json_with_token(
"POST",
"/api/fs/read",
json!({ "path": file_path.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert!(json["data"].is_null());
}
#[tokio::test]
async fn image_base64_with_workspace_field_accepts_non_home_path() {
let sandbox = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let (mut app, services) = build_app_with_file_roots(vec![sandbox.path().to_path_buf()]).await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let file_path = workspace.path().join("preview.png");
std::fs::write(&file_path, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).unwrap();
let req = json_with_token(
"POST",
"/api/fs/image-base64",
json!({
"path": file_path.to_str().unwrap(),
"workspace": workspace.path().to_str().unwrap()
}),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert!(json["data"].as_str().unwrap().starts_with("data:image/png;base64,"));
}
#[tokio::test]
async fn write_file_creates_and_returns_true() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("new_file.txt");
let req = json_with_token(
"POST",
"/api/fs/write",
json!({
"path": file_path.to_str().unwrap(),
"data": "written via api",
"workspace": dir.path().to_str().unwrap()
}),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"], true);
// Verify file actually written
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "written via api");
}
#[tokio::test]
async fn read_file_buffer_returns_base64() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("binary.bin");
std::fs::write(&file_path, [0x00, 0xFF, 0xAB]).unwrap();
let req = json_with_token(
"POST",
"/api/fs/read-buffer",
json!({ "path": file_path.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let encoded = json["data"].as_str().unwrap();
// Verify base64 roundtrip
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD.decode(encoded).unwrap();
assert_eq!(decoded, vec![0x00, 0xFF, 0xAB]);
}
// ===========================================================================
// File management
// ===========================================================================
#[tokio::test]
async fn copy_files_to_workspace() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let src_dir = tempfile::tempdir().unwrap();
std::fs::write(src_dir.path().join("source.txt"), "content").unwrap();
let ws_dir = tempfile::tempdir().unwrap();
let req = json_with_token(
"POST",
"/api/fs/copy",
json!({
"file_paths": [src_dir.path().join("source.txt").to_str().unwrap()],
"workspace": ws_dir.path().to_str().unwrap()
}),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert!(!json["data"]["copied_files"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn remove_entry_deletes_file() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("to_delete.txt");
std::fs::write(&file_path, "bye").unwrap();
let req = json_with_token(
"POST",
"/api/fs/remove",
json!({
"path": file_path.to_str().unwrap(),
"workspace": dir.path().to_str().unwrap()
}),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(!file_path.exists());
}
#[tokio::test]
async fn rename_entry_returns_new_path() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let dir = tempfile::tempdir().unwrap();
let old_path = dir.path().join("old.txt");
std::fs::write(&old_path, "data").unwrap();
let req = json_with_token(
"POST",
"/api/fs/rename",
json!({
"path": old_path.to_str().unwrap(),
"new_name": "new.txt"
}),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let new_path = json["data"]["new_path"].as_str().unwrap();
assert!(new_path.contains("new.txt"));
assert!(!old_path.exists());
}
#[tokio::test]
async fn create_temp_file_returns_path() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let req = json_with_token(
"POST",
"/api/fs/temp",
json!({ "file_name": "temp_test.txt" }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let path = json["data"].as_str().unwrap();
assert!(path.contains("temp_test.txt"));
assert!(std::path::Path::new(path).exists());
// Cleanup
let _ = std::fs::remove_file(path);
}
// ===========================================================================
// Image processing
// ===========================================================================
#[tokio::test]
async fn get_image_base64_returns_data_url() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let dir = tempfile::tempdir().unwrap();
let img_path = dir.path().join("pixel.png");
// Minimal valid 1x1 PNG
let png_bytes: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00,
0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2,
0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
std::fs::write(&img_path, png_bytes).unwrap();
let req = json_with_token(
"POST",
"/api/fs/image-base64",
json!({ "path": img_path.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let data_url = json["data"].as_str().unwrap();
assert!(data_url.starts_with("data:image/png;base64,"));
}
#[tokio::test]
async fn fetch_remote_image_non_whitelisted_returns_placeholder_svg() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let req = json_with_token(
"POST",
"/api/fs/fetch-remote-image",
json!({ "url": "https://evil.example.com/image.png" }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let data_url = json["data"].as_str().unwrap();
assert!(
data_url.starts_with("data:image/svg+xml"),
"expected placeholder SVG for non-whitelisted host"
);
}
// ===========================================================================
// ZIP operations
// ===========================================================================
#[tokio::test]
async fn create_zip_with_text_content() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("test.zip");
let req = json_with_token(
"POST",
"/api/fs/zip",
json!({
"path": zip_path.to_str().unwrap(),
"files": [
{ "name": "greeting.txt", "content": "hello zip" }
]
}),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"], true);
assert!(zip_path.exists());
}
#[tokio::test]
async fn cancel_zip_nonexistent_returns_false() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let req = json_with_token(
"POST",
"/api/fs/zip/cancel",
json!({ "request_id": "nonexistent-id" }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"], false);
}
// ===========================================================================
// File watch
// ===========================================================================
#[tokio::test]
async fn watch_stop_all_succeeds() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let req = json_with_token("POST", "/api/fs/watch/stop-all", json!({}), &token, &csrf);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
}
// ===========================================================================
// Snapshot operations
// ===========================================================================
#[tokio::test]
async fn snapshot_init_and_compare_on_plain_dir() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path();
std::fs::write(workspace.join("file.txt"), "initial").unwrap();
// Init snapshot
let req = json_with_token(
"POST",
"/api/fs/snapshot/init",
json!({ "workspace": workspace.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["data"]["mode"], "snapshot");
// Get info
let req = json_with_token(
"POST",
"/api/fs/snapshot/info",
json!({ "workspace": workspace.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["mode"], "snapshot");
// Modify file and compare
std::fs::write(workspace.join("file.txt"), "modified").unwrap();
let req = json_with_token(
"POST",
"/api/fs/snapshot/compare",
json!({ "workspace": workspace.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let unstaged = json["data"]["unstaged"].as_array().unwrap();
assert!(!unstaged.is_empty(), "should detect unstaged modification");
assert_eq!(unstaged[0]["operation"], "modify");
// Get baseline content
let req = json_with_token(
"POST",
"/api/fs/snapshot/baseline",
json!({
"workspace": workspace.to_str().unwrap(),
"file_path": "file.txt"
}),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"], "initial");
// Dispose
let req = json_with_token(
"POST",
"/api/fs/snapshot/dispose",
json!({ "workspace": workspace.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn snapshot_init_git_repo() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
// Create a temporary git repo
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path();
let repo = git2::Repository::init(workspace).unwrap();
std::fs::write(workspace.join("readme.md"), "# hello").unwrap();
// Stage and commit
let mut index = repo.index().unwrap();
index.add_path(std::path::Path::new("readme.md")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
// Init snapshot — should detect git-repo mode
let req = json_with_token(
"POST",
"/api/fs/snapshot/init",
json!({ "workspace": workspace.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["mode"], "git-repo");
assert!(json["data"]["branch"].is_string());
// Branches
let req = json_with_token(
"POST",
"/api/fs/snapshot/branches",
json!({ "workspace": workspace.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let branches = json["data"].as_array().unwrap();
assert!(!branches.is_empty());
// Dispose
let req = json_with_token(
"POST",
"/api/fs/snapshot/dispose",
json!({ "workspace": workspace.to_str().unwrap() }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
// ===========================================================================
// Path traversal rejection
// ===========================================================================
#[tokio::test]
async fn path_traversal_rejected() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let req = json_with_token(
"POST",
"/api/fs/read",
json!({ "path": "/tmp/../../../etc/passwd" }),
&token,
&csrf,
);
let resp = app.oneshot(req).await.unwrap();
// Should be rejected (400 bad request)
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ===========================================================================
// /api/fs/upload — multipart upload
// ===========================================================================
struct UploadMultipart {
boundary: String,
parts: Vec<u8>,
}
impl UploadMultipart {
fn new() -> Self {
Self {
boundary: "----TestBoundaryFsUpload9XyZ".to_owned(),
parts: Vec::new(),
}
}
fn add_text(mut self, name: &str, value: &str) -> Self {
self.parts
.extend_from_slice(format!("--{}\r\n", self.boundary).as_bytes());
self.parts
.extend_from_slice(format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes());
self.parts.extend_from_slice(value.as_bytes());
self.parts.extend_from_slice(b"\r\n");
self
}
fn add_file(mut self, name: &str, filename: &str, mime: &str, data: &[u8]) -> Self {
self.parts
.extend_from_slice(format!("--{}\r\n", self.boundary).as_bytes());
self.parts.extend_from_slice(
format!("Content-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\n").as_bytes(),
);
self.parts
.extend_from_slice(format!("Content-Type: {mime}\r\n\r\n").as_bytes());
self.parts.extend_from_slice(data);
self.parts.extend_from_slice(b"\r\n");
self
}
fn build(mut self) -> (String, Vec<u8>) {
self.parts
.extend_from_slice(format!("--{}--\r\n", self.boundary).as_bytes());
let content_type = format!("multipart/form-data; boundary={}", self.boundary);
(content_type, self.parts)
}
}
fn upload_request(content_type: &str, body: Vec<u8>, token: &str, csrf: &str) -> axum::http::Request<axum::body::Body> {
let content_length = body.len();
axum::http::Request::builder()
.method("POST")
.uri("/api/fs/upload")
.header("content-type", content_type)
.header("content-length", content_length)
.header("authorization", format!("Bearer {token}"))
.header("x-csrf-token", csrf)
.header("cookie", format!("nomifun-csrf-token={csrf}"))
.body(axum::body::Body::from(body))
.unwrap()
}
#[tokio::test]
async fn upload_accepts_small_png_and_returns_readable_path() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
// Minimal valid 1x1 PNG (67 bytes).
let png_bytes: Vec<u8> = vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00,
0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2,
0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
let conv_id = format!("conv-upload-{}", std::process::id());
let (content_type, body) = UploadMultipart::new()
.add_file("file", "paste.png", "image/png", &png_bytes)
.add_text("conversation_id", &conv_id)
.build();
let req = upload_request(&content_type, body, &token, &csrf);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
let path = json["data"].as_str().expect("data should be a string path");
let p = std::path::Path::new(path);
assert!(p.is_absolute(), "returned path must be absolute: {path}");
assert_eq!(p.file_name().unwrap().to_string_lossy(), "paste.png");
// conversation_id routing produced a sub-directory of that name.
assert_eq!(p.parent().unwrap().file_name().unwrap().to_string_lossy(), conv_id);
// File contents must match what we uploaded.
let on_disk = std::fs::read(p).expect("uploaded file should be readable");
assert_eq!(on_disk, png_bytes);
// Cleanup.
let _ = std::fs::remove_file(p);
let _ = std::fs::remove_dir(p.parent().unwrap());
}
#[tokio::test]
async fn upload_uses_content_disposition_filename_when_file_name_missing() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let bytes = b"hello upload".to_vec();
let unique = format!("dispo-{}.bin", std::process::id());
let (content_type, body) = UploadMultipart::new()
.add_file("file", &unique, "application/octet-stream", &bytes)
.build();
let req = upload_request(&content_type, body, &token, &csrf);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let path = json["data"].as_str().unwrap();
assert!(path.ends_with(&unique));
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn upload_prefers_explicit_file_name_field_over_dispo() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let bytes = b"pref".to_vec();
let explicit = format!("explicit-{}.bin", std::process::id());
let (content_type, body) = UploadMultipart::new()
.add_file("file", "dispo.bin", "application/octet-stream", &bytes)
.add_text("file_name", &explicit)
.build();
let req = upload_request(&content_type, body, &token, &csrf);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let path = json["data"].as_str().unwrap();
assert!(path.ends_with(&explicit), "expected filename {explicit}, got {path}");
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn upload_missing_file_field_returns_400() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
let (content_type, body) = UploadMultipart::new().add_text("file_name", "ignored.png").build();
let req = upload_request(&content_type, body, &token, &csrf);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = body_json(resp).await;
assert_eq!(json["success"], false);
}
#[tokio::test]
async fn upload_body_exceeding_30mb_returns_413() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
// 31 MB payload comfortably exceeds UPLOAD_MAX_SIZE (30 MB).
let big = vec![0u8; 31 * 1024 * 1024];
let (content_type, body) = UploadMultipart::new()
.add_file("file", "big.bin", "application/octet-stream", &big)
.build();
let req = upload_request(&content_type, body, &token, &csrf);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}