f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
453 lines
14 KiB
Rust
453 lines
14 KiB
Rust
//! Integration tests for file management operations (task 7.5).
|
|
//!
|
|
//! Covers `copy_files_to_workspace`, `remove_entry`, `rename_entry`, and
|
|
//! `create_temp_file` through the `IFileService` trait, including path
|
|
//! validation, event broadcast, and cache invalidation.
|
|
|
|
use std::fs;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use nomifun_api_types::WebSocketMessage;
|
|
use nomifun_file::{FileService, IFileService};
|
|
use nomifun_realtime::EventBroadcaster;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test helpers (shared with file_read_write.rs pattern)
|
|
// -----------------------------------------------------------------------
|
|
|
|
struct RecordingBroadcaster {
|
|
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
|
}
|
|
|
|
impl RecordingBroadcaster {
|
|
fn new() -> Self {
|
|
Self {
|
|
events: Mutex::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
fn take_events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
|
|
let mut guard = self.events.lock().unwrap();
|
|
std::mem::take(&mut *guard)
|
|
}
|
|
}
|
|
|
|
impl EventBroadcaster for RecordingBroadcaster {
|
|
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
|
self.events.lock().unwrap().push(event);
|
|
}
|
|
}
|
|
|
|
struct NoopBroadcaster;
|
|
|
|
impl EventBroadcaster for NoopBroadcaster {
|
|
fn broadcast(&self, _event: WebSocketMessage<serde_json::Value>) {}
|
|
}
|
|
|
|
fn make_service(root: &std::path::Path) -> FileService {
|
|
FileService::new(Arc::new(NoopBroadcaster), vec![root.to_path_buf()])
|
|
}
|
|
|
|
fn make_service_with_recorder(root: &std::path::Path) -> (FileService, Arc<RecordingBroadcaster>) {
|
|
let recorder = Arc::new(RecordingBroadcaster::new());
|
|
let svc = FileService::new(recorder.clone(), vec![root.to_path_buf()]);
|
|
(svc, recorder)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// copyFilesToWorkspace
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn copy_files_single_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let src_dir = dir.path().join("src");
|
|
let ws_dir = dir.path().join("ws");
|
|
fs::create_dir_all(&src_dir).unwrap();
|
|
fs::create_dir_all(&ws_dir).unwrap();
|
|
fs::write(src_dir.join("a.txt"), "hello").unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let paths = vec![src_dir.join("a.txt").to_string_lossy().into_owned()];
|
|
let result = svc
|
|
.copy_files_to_workspace(&paths, ws_dir.to_str().unwrap(), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result.copied_files.len(), 1);
|
|
assert!(result.failed_files.is_empty());
|
|
// Without source_root, file should be at workspace root
|
|
assert_eq!(fs::read_to_string(ws_dir.join("a.txt")).unwrap(), "hello");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn copy_files_with_source_root_preserves_structure() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let src_dir = dir.path().join("project");
|
|
let ws_dir = dir.path().join("ws");
|
|
fs::create_dir_all(src_dir.join("utils")).unwrap();
|
|
fs::create_dir_all(&ws_dir).unwrap();
|
|
fs::write(src_dir.join("utils/helper.ts"), "export {}").unwrap();
|
|
fs::write(src_dir.join("index.ts"), "import {}").unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let paths = vec![
|
|
src_dir.join("utils/helper.ts").to_string_lossy().into_owned(),
|
|
src_dir.join("index.ts").to_string_lossy().into_owned(),
|
|
];
|
|
|
|
let result = svc
|
|
.copy_files_to_workspace(&paths, ws_dir.to_str().unwrap(), Some(src_dir.to_str().unwrap()))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result.copied_files.len(), 2);
|
|
assert!(result.failed_files.is_empty());
|
|
// Directory structure preserved relative to source_root
|
|
assert_eq!(fs::read_to_string(ws_dir.join("utils/helper.ts")).unwrap(), "export {}");
|
|
assert_eq!(fs::read_to_string(ws_dir.join("index.ts")).unwrap(), "import {}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn copy_files_partial_failure() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let src_dir = dir.path().join("src");
|
|
let ws_dir = dir.path().join("ws");
|
|
fs::create_dir_all(&src_dir).unwrap();
|
|
fs::create_dir_all(&ws_dir).unwrap();
|
|
fs::write(src_dir.join("good.txt"), "ok").unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let paths = vec![
|
|
src_dir.join("good.txt").to_string_lossy().into_owned(),
|
|
src_dir.join("missing.txt").to_string_lossy().into_owned(),
|
|
];
|
|
|
|
let result = svc
|
|
.copy_files_to_workspace(&paths, ws_dir.to_str().unwrap(), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result.copied_files.len(), 1);
|
|
assert_eq!(result.failed_files.len(), 1);
|
|
assert!(result.failed_files[0].contains("missing.txt"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn copy_files_empty_list() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let ws_dir = dir.path().join("ws");
|
|
fs::create_dir_all(&ws_dir).unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let result = svc
|
|
.copy_files_to_workspace(&[], ws_dir.to_str().unwrap(), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(result.copied_files.is_empty());
|
|
assert!(result.failed_files.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn copy_files_directory_in_list_is_failed() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let sub = dir.path().join("subdir");
|
|
let ws = dir.path().join("ws");
|
|
fs::create_dir_all(&sub).unwrap();
|
|
fs::create_dir_all(&ws).unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let paths = vec![sub.to_string_lossy().into_owned()];
|
|
let result = svc
|
|
.copy_files_to_workspace(&paths, ws.to_str().unwrap(), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Directories are not valid source files
|
|
assert!(result.copied_files.is_empty());
|
|
assert_eq!(result.failed_files.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn copy_files_outside_sandbox_fails() {
|
|
let sandbox = tempfile::tempdir().unwrap();
|
|
let outside = tempfile::tempdir().unwrap();
|
|
let ws = sandbox.path().join("ws");
|
|
fs::create_dir_all(&ws).unwrap();
|
|
fs::write(outside.path().join("secret.txt"), "secret").unwrap();
|
|
|
|
let svc = make_service(sandbox.path());
|
|
let paths = vec![outside.path().join("secret.txt").to_string_lossy().into_owned()];
|
|
|
|
let result = svc
|
|
.copy_files_to_workspace(&paths, ws.to_str().unwrap(), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(result.copied_files.is_empty());
|
|
assert_eq!(result.failed_files.len(), 1);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// removeEntry
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn remove_entry_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let file = dir.path().join("to_delete.txt");
|
|
fs::write(&file, "bye").unwrap();
|
|
assert!(file.exists());
|
|
|
|
let svc = make_service(dir.path());
|
|
let ws = dir.path().to_str().unwrap();
|
|
svc.remove_entry(file.to_str().unwrap(), ws).await.unwrap();
|
|
|
|
assert!(!file.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remove_entry_directory() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let sub = dir.path().join("subdir");
|
|
fs::create_dir(&sub).unwrap();
|
|
fs::write(sub.join("inner.txt"), "data").unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let ws = dir.path().to_str().unwrap();
|
|
svc.remove_entry(sub.to_str().unwrap(), ws).await.unwrap();
|
|
|
|
assert!(!sub.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remove_entry_nonexistent_errors() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let fake = dir.path().join("ghost.txt");
|
|
|
|
let svc = make_service(dir.path());
|
|
let ws = dir.path().to_str().unwrap();
|
|
let result = svc.remove_entry(fake.to_str().unwrap(), ws).await;
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remove_entry_emits_delete_event() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let file = dir.path().join("event_del.txt");
|
|
fs::write(&file, "data").unwrap();
|
|
|
|
let (svc, recorder) = make_service_with_recorder(dir.path());
|
|
let ws = dir.path().to_str().unwrap();
|
|
svc.remove_entry(file.to_str().unwrap(), ws).await.unwrap();
|
|
|
|
let events = recorder.take_events();
|
|
assert_eq!(events.len(), 1);
|
|
|
|
let event = &events[0];
|
|
assert_eq!(event.name, "fileStream.contentUpdate");
|
|
assert_eq!(event.data["operation"], "delete");
|
|
assert!(event.data.get("content").is_none());
|
|
assert_eq!(event.data["relative_path"], "event_del.txt");
|
|
assert!(event.data["file_path"].as_str().unwrap().contains("event_del.txt"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remove_entry_invalidates_cache() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
fs::write(dir.path().join("a.txt"), "a").unwrap();
|
|
fs::write(dir.path().join("b.txt"), "b").unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let ws = dir.path().to_str().unwrap();
|
|
|
|
// Populate cache
|
|
let files = svc.list_workspace_files(ws).await.unwrap();
|
|
assert_eq!(files.len(), 2);
|
|
|
|
// Remove a file
|
|
let target = dir.path().join("a.txt");
|
|
svc.remove_entry(target.to_str().unwrap(), ws).await.unwrap();
|
|
|
|
// Cache should be invalidated, so we see only 1 file
|
|
let files = svc.list_workspace_files(ws).await.unwrap();
|
|
assert_eq!(files.len(), 1);
|
|
assert_eq!(files[0].name, "b.txt");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remove_entry_path_traversal_rejected() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let ws = dir.path().to_str().unwrap();
|
|
let result = svc.remove_entry("../../etc/passwd", ws).await;
|
|
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(err.contains("traversal"), "got: {err}");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// renameEntry
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn rename_entry_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let old = dir.path().join("old.txt");
|
|
fs::write(&old, "data").unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let new_path = svc.rename_entry(old.to_str().unwrap(), "new.txt").await.unwrap();
|
|
|
|
assert!(!old.exists());
|
|
assert!(new_path.contains("new.txt"));
|
|
assert_eq!(fs::read_to_string(dir.path().join("new.txt")).unwrap(), "data");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_entry_directory() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let old = dir.path().join("old_dir");
|
|
fs::create_dir(&old).unwrap();
|
|
fs::write(old.join("inner.txt"), "inner").unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let new_path = svc.rename_entry(old.to_str().unwrap(), "new_dir").await.unwrap();
|
|
|
|
assert!(!old.exists());
|
|
assert!(std::path::Path::new(&new_path).is_dir());
|
|
assert_eq!(
|
|
fs::read_to_string(dir.path().join("new_dir/inner.txt")).unwrap(),
|
|
"inner"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_entry_target_exists_errors() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let old = dir.path().join("old.txt");
|
|
let existing = dir.path().join("existing.txt");
|
|
fs::write(&old, "old").unwrap();
|
|
fs::write(&existing, "existing").unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let result = svc.rename_entry(old.to_str().unwrap(), "existing.txt").await;
|
|
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(err.contains("already exists"), "got: {err}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_entry_nonexistent_source_errors() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let fake = dir.path().join("missing.txt");
|
|
|
|
let svc = make_service(dir.path());
|
|
let result = svc.rename_entry(fake.to_str().unwrap(), "new.txt").await;
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_entry_path_traversal_rejected() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let result = svc.rename_entry("../../etc/passwd", "new.txt").await;
|
|
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(err.contains("traversal"), "got: {err}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_entry_rejects_path_separator_in_name() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let file = dir.path().join("a.txt");
|
|
fs::write(&file, "data").unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let result = svc.rename_entry(file.to_str().unwrap(), "sub/new.txt").await;
|
|
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(err.contains("path separator"), "got: {err}");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// createTempFile
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn create_temp_file_normal() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let path = svc.create_temp_file("test.txt").await.unwrap();
|
|
|
|
assert!(path.contains("test.txt"));
|
|
assert!(std::path::Path::new(&path).exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_temp_file_is_empty() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let path = svc.create_temp_file("empty.txt").await.unwrap();
|
|
|
|
let content = fs::read_to_string(&path).unwrap();
|
|
assert!(content.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_temp_file_path_in_nomifun_dir() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let svc = make_service(dir.path());
|
|
let path = svc.create_temp_file("check.txt").await.unwrap();
|
|
|
|
assert!(path.contains("nomifun"), "temp path should be under nomifun dir");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_temp_file_rejects_traversal() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let svc = make_service(dir.path());
|
|
|
|
let result = svc.create_temp_file("../../malicious.txt").await;
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(err.contains("traversal"), "expected traversal error, got: {err}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_temp_file_rejects_path_separator() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let svc = make_service(dir.path());
|
|
|
|
let result = svc.create_temp_file("sub/file.txt").await;
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("path separator"),
|
|
"expected path separator error, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_temp_file_rejects_null_byte() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let svc = make_service(dir.path());
|
|
|
|
let result = svc.create_temp_file("evil\0name.txt").await;
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(err.contains("traversal"), "expected traversal error, got: {err}");
|
|
}
|