Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,953 @@
|
||||
//! 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);
|
||||
}
|
||||
Reference in New Issue
Block a user