Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
//! Integration tests for directory browsing and file metadata (task 7.3).
|
||||
//!
|
||||
//! These tests exercise the full `FileService` through the `IFileService` trait,
|
||||
//! including path validation, .gitignore handling, and caching behavior.
|
||||
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_file::{FileService, IFileService};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
|
||||
/// A no-op broadcaster for testing (events are silently discarded).
|
||||
struct NoopBroadcaster;
|
||||
|
||||
impl EventBroadcaster for NoopBroadcaster {
|
||||
fn broadcast(&self, _event: WebSocketMessage<serde_json::Value>) {}
|
||||
}
|
||||
|
||||
/// Create a `FileService` whose sandbox is rooted at the given temp directory.
|
||||
fn make_service(root: &std::path::Path) -> FileService {
|
||||
FileService::new(Arc::new(NoopBroadcaster), vec![root.to_path_buf()])
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getFilesByDir
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_files_by_dir_lists_children() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("a.txt"), "hello").unwrap();
|
||||
fs::create_dir(dir.path().join("sub")).unwrap();
|
||||
fs::write(dir.path().join("sub/nested.txt"), "nested").unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let root = dir.path().to_str().unwrap();
|
||||
|
||||
let items = svc.get_files_by_dir(root, root).await.unwrap();
|
||||
|
||||
// Should have: sub/ and a.txt
|
||||
assert_eq!(items.len(), 2);
|
||||
|
||||
let sub = items.iter().find(|i| i.name == "sub").unwrap();
|
||||
assert!(sub.is_dir);
|
||||
assert_eq!(sub.children.len(), 1);
|
||||
assert_eq!(sub.children[0].name, "nested.txt");
|
||||
|
||||
let file = items.iter().find(|i| i.name == "a.txt").unwrap();
|
||||
assert!(!file.is_dir);
|
||||
assert!(file.children.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_files_by_dir_empty_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
let root = dir.path().to_str().unwrap();
|
||||
|
||||
let items = svc.get_files_by_dir(root, root).await.unwrap();
|
||||
assert!(items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_files_by_dir_subdirectory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sub = dir.path().join("src");
|
||||
fs::create_dir(&sub).unwrap();
|
||||
fs::write(sub.join("main.rs"), "fn main(){}").unwrap();
|
||||
fs::write(sub.join("lib.rs"), "pub mod foo;").unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let root = dir.path().to_str().unwrap();
|
||||
|
||||
let items = svc.get_files_by_dir(sub.to_str().unwrap(), root).await.unwrap();
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
// Relative paths should be relative to root, not to sub
|
||||
assert!(items[0].relative_path.starts_with("src/"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_files_by_dir_rejects_path_outside_sandbox() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
fs::write(outside.path().join("secret.txt"), "secret").unwrap();
|
||||
|
||||
let svc = make_service(sandbox.path());
|
||||
|
||||
let result = svc
|
||||
.get_files_by_dir(outside.path().to_str().unwrap(), outside.path().to_str().unwrap())
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_files_by_dir_nonexistent_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join("nonexistent");
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let result = svc
|
||||
.get_files_by_dir(fake.to_str().unwrap(), dir.path().to_str().unwrap())
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_files_by_dir_directories_sorted_first() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("z_file.txt"), "").unwrap();
|
||||
fs::create_dir(dir.path().join("a_dir")).unwrap();
|
||||
fs::write(dir.path().join("a_file.txt"), "").unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let root = dir.path().to_str().unwrap();
|
||||
|
||||
let items = svc.get_files_by_dir(root, root).await.unwrap();
|
||||
|
||||
// Directory first
|
||||
assert!(items[0].is_dir);
|
||||
assert_eq!(items[0].name, "a_dir");
|
||||
// Then files alphabetically
|
||||
assert_eq!(items[1].name, "a_file.txt");
|
||||
assert_eq!(items[2].name, "z_file.txt");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// listWorkspaceFiles
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_workspace_files_recursive() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("root.txt"), "").unwrap();
|
||||
fs::create_dir(dir.path().join("a")).unwrap();
|
||||
fs::write(dir.path().join("a/nested.txt"), "").unwrap();
|
||||
fs::create_dir(dir.path().join("a/b")).unwrap();
|
||||
fs::write(dir.path().join("a/b/deep.txt"), "").unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let files = svc.list_workspace_files(dir.path().to_str().unwrap()).await.unwrap();
|
||||
|
||||
assert_eq!(files.len(), 3);
|
||||
let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"root.txt"));
|
||||
assert!(names.contains(&"nested.txt"));
|
||||
assert!(names.contains(&"deep.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_workspace_files_respects_gitignore() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join(".gitignore"), "*.log\nbuild/\n").unwrap();
|
||||
fs::write(dir.path().join("app.rs"), "").unwrap();
|
||||
fs::write(dir.path().join("debug.log"), "").unwrap();
|
||||
fs::create_dir(dir.path().join("build")).unwrap();
|
||||
fs::write(dir.path().join("build/output.js"), "").unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let files = svc.list_workspace_files(dir.path().to_str().unwrap()).await.unwrap();
|
||||
|
||||
let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"app.rs"));
|
||||
assert!(names.contains(&".gitignore"));
|
||||
assert!(!names.contains(&"debug.log"));
|
||||
assert!(!names.contains(&"output.js"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_workspace_files_empty_workspace() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let files = svc.list_workspace_files(dir.path().to_str().unwrap()).await.unwrap();
|
||||
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_workspace_files_rejects_outside_sandbox() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
|
||||
let svc = make_service(sandbox.path());
|
||||
|
||||
let result = svc.list_workspace_files(outside.path().to_str().unwrap()).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_workspace_files_cache_hit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("file.txt"), "data").unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let root = dir.path().to_str().unwrap();
|
||||
|
||||
// First call populates cache
|
||||
let first = svc.list_workspace_files(root).await.unwrap();
|
||||
assert_eq!(first.len(), 1);
|
||||
|
||||
// Add a file — should NOT appear due to cache
|
||||
fs::write(dir.path().join("new.txt"), "new").unwrap();
|
||||
let second = svc.list_workspace_files(root).await.unwrap();
|
||||
assert_eq!(second.len(), 1); // Still cached
|
||||
|
||||
// Invalidate cache
|
||||
svc.invalidate_cache(&std::fs::canonicalize(dir.path()).unwrap().to_string_lossy());
|
||||
|
||||
// Now should see new file
|
||||
let third = svc.list_workspace_files(root).await.unwrap();
|
||||
assert_eq!(third.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_workspace_files_relative_paths() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join("src/utils")).unwrap();
|
||||
fs::write(dir.path().join("src/utils/helper.ts"), "").unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let files = svc.list_workspace_files(dir.path().to_str().unwrap()).await.unwrap();
|
||||
|
||||
let helper = files.iter().find(|f| f.name == "helper.ts").unwrap();
|
||||
assert_eq!(helper.relative_path, "src/utils/helper.ts");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn list_workspace_files_skips_directory_symlinks() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let skill_dir = dir.path().join("builtin-skills/auto-inject/nomifun-skills");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
fs::write(skill_dir.join("SKILL.md"), "---\ndescription: test\n---\nbody").unwrap();
|
||||
|
||||
let workspace_skills_dir = dir.path().join("workspace/.claude/skills");
|
||||
fs::create_dir_all(&workspace_skills_dir).unwrap();
|
||||
std::os::unix::fs::symlink(&skill_dir, workspace_skills_dir.join("nomifun-skills")).unwrap();
|
||||
|
||||
let svc = make_service(dir.path().join("workspace").as_path());
|
||||
let files = svc
|
||||
.list_workspace_files(dir.path().join("workspace").to_str().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
files.iter().all(|file| file.name != "nomifun-skills"),
|
||||
"directory symlink should not be surfaced as a file: {files:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getFileMetadata
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_metadata_text_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("hello.txt");
|
||||
fs::write(&file, "hello world").unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let meta = svc.get_file_metadata(file.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert_eq!(meta.name, "hello.txt");
|
||||
assert_eq!(meta.size, 11);
|
||||
assert_eq!(meta.mime_type, "text/plain");
|
||||
assert!(!meta.is_directory);
|
||||
assert!(meta.last_modified > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_metadata_image() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let png = dir.path().join("photo.png");
|
||||
fs::write(&png, [0x89, 0x50, 0x4E, 0x47]).unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let meta = svc.get_file_metadata(png.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert_eq!(meta.mime_type, "image/png");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_metadata_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sub = dir.path().join("mydir");
|
||||
fs::create_dir(&sub).unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let meta = svc.get_file_metadata(sub.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert!(meta.is_directory);
|
||||
assert_eq!(meta.mime_type, "inode/directory");
|
||||
assert_eq!(meta.name, "mydir");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_metadata_nonexistent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join("missing.txt");
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.get_file_metadata(fake.to_str().unwrap(), None).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_metadata_outside_sandbox() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let secret = outside.path().join("secret.txt");
|
||||
fs::write(&secret, "secret").unwrap();
|
||||
|
||||
let svc = make_service(sandbox.path());
|
||||
let result = svc.get_file_metadata(secret.to_str().unwrap(), None).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_metadata_json_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("config.json");
|
||||
fs::write(&file, r#"{"key":"value"}"#).unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let meta = svc.get_file_metadata(file.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert_eq!(meta.mime_type, "application/json");
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
//! 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}");
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
//! Integration tests for file read/write operations (task 7.4).
|
||||
//!
|
||||
//! These tests exercise `read_file`, `read_file_buffer`, and `write_file`
|
||||
//! through the `IFileService` trait, including path validation, 256 MB size
|
||||
//! limit, non-existent file handling, and contentUpdate event broadcast.
|
||||
|
||||
use std::fs;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_file::{FileService, IFileService};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
|
||||
/// A broadcaster that records every event for later assertion.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op broadcaster for tests that don't need event verification.
|
||||
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)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// readFile
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_normal_utf8() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("hello.txt");
|
||||
fs::write(&file, "hello world").unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.read_file(file.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert_eq!(result.as_deref(), Some("hello world"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("empty.txt");
|
||||
fs::write(&file, "").unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.read_file(file.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert_eq!(result.as_deref(), Some(""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_nonexistent_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join("missing.txt");
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.read_file(fake.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_path_traversal_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.read_file("../../etc/passwd", None).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 read_file_multiline_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("multi.txt");
|
||||
let content = "line 1\nline 2\nline 3\n";
|
||||
fs::write(&file, content).unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.read_file(file.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert_eq!(result.as_deref(), Some(content));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_unicode_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("unicode.txt");
|
||||
let content = "你好世界 🌍 café résumé";
|
||||
fs::write(&file, content).unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.read_file(file.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert_eq!(result.as_deref(), Some(content));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_with_extra_workspace_root_outside_home() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let file = workspace.path().join("outside.txt");
|
||||
fs::write(&file, "workspace content").unwrap();
|
||||
|
||||
let svc = make_service(sandbox.path());
|
||||
let result = svc
|
||||
.read_file(file.to_str().unwrap(), Some(workspace.path()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.as_deref(), Some("workspace content"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_rejects_outside_sandbox_without_workspace() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let file = outside.path().join("secret.txt");
|
||||
fs::write(&file, "secret").unwrap();
|
||||
|
||||
let svc = make_service(sandbox.path());
|
||||
let err = svc.read_file(file.to_str().unwrap(), None).await.unwrap_err();
|
||||
|
||||
assert!(matches!(err, nomifun_common::AppError::Forbidden(_)));
|
||||
assert_eq!(err.error_code(), "PATH_OUTSIDE_SANDBOX");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_returns_none_for_missing_file_in_sandbox() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let missing = sandbox.path().join("missing.txt");
|
||||
|
||||
let svc = make_service(sandbox.path());
|
||||
let result = svc.read_file(missing.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_rejects_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let folder = dir.path().join("nomifun-skills");
|
||||
fs::create_dir(&folder).unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let err = svc.read_file(folder.to_str().unwrap(), None).await.unwrap_err();
|
||||
|
||||
assert!(matches!(err, nomifun_common::AppError::BadRequest(_)));
|
||||
assert!(err.to_string().contains("is a directory"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_buffer_with_extra_workspace_root() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let file = workspace.path().join("outside.bin");
|
||||
let bytes = vec![1, 2, 3, 4];
|
||||
fs::write(&file, &bytes).unwrap();
|
||||
|
||||
let svc = make_service(sandbox.path());
|
||||
let result = svc
|
||||
.read_file_buffer(file.to_str().unwrap(), Some(workspace.path()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.as_deref(), Some(bytes.as_slice()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_nonexistent_inside_workspace_prefix_returns_none() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let missing = workspace.path().join("missing.txt");
|
||||
|
||||
let svc = make_service(sandbox.path());
|
||||
let result = svc
|
||||
.read_file(missing.to_str().unwrap(), Some(workspace.path()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// readFileBuffer
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_buffer_normal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("data.bin");
|
||||
let data: Vec<u8> = vec![0x00, 0xFF, 0x42, 0x89, 0x50];
|
||||
fs::write(&file, &data).unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.read_file_buffer(file.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert_eq!(result.as_deref(), Some(data.as_slice()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_buffer_nonexistent_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join("missing.bin");
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.read_file_buffer(fake.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_buffer_path_traversal_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.read_file_buffer("../../etc/passwd", None).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// writeFile
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_normal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("output.txt");
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let ws = dir.path().to_str().unwrap();
|
||||
let ok = svc.write_file(file.to_str().unwrap(), b"hello", ws).await.unwrap();
|
||||
|
||||
assert!(ok);
|
||||
assert_eq!(fs::read_to_string(&file).unwrap(), "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_creates_new_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("new_file.txt");
|
||||
assert!(!file.exists());
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let ws = dir.path().to_str().unwrap();
|
||||
let ok = svc.write_file(file.to_str().unwrap(), b"created", ws).await.unwrap();
|
||||
|
||||
assert!(ok);
|
||||
assert!(file.exists());
|
||||
assert_eq!(fs::read_to_string(&file).unwrap(), "created");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_parent_not_exists_returns_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("nonexistent_dir/file.txt");
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let ws = dir.path().to_str().unwrap();
|
||||
let result = svc.write_file(file.to_str().unwrap(), b"data", ws).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_path_traversal_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let ws = dir.path().to_str().unwrap();
|
||||
let result = svc.write_file("../../tmp/evil.txt", b"bad", ws).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_outside_sandbox_rejected() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let target = outside.path().join("evil.txt");
|
||||
|
||||
let svc = make_service(sandbox.path());
|
||||
let ws = sandbox.path().to_str().unwrap();
|
||||
let result = svc.write_file(target.to_str().unwrap(), b"bad", ws).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// contentUpdate event
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_emits_content_update_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("event_test.txt");
|
||||
|
||||
let (svc, recorder) = make_service_with_recorder(dir.path());
|
||||
let ws = dir.path().to_str().unwrap();
|
||||
|
||||
svc.write_file(file.to_str().unwrap(), b"event content", 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["content"], "event content");
|
||||
assert_eq!(event.data["workspace"], ws);
|
||||
assert_eq!(event.data["operation"], "write");
|
||||
// file_path should be the canonical path
|
||||
assert!(
|
||||
event.data["file_path"].as_str().unwrap().contains("event_test.txt"),
|
||||
"file_path should contain the file name"
|
||||
);
|
||||
// relative_path should be relative to workspace
|
||||
assert_eq!(event.data["relative_path"], "event_test.txt");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_binary_omits_content_in_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("binary.bin");
|
||||
// Invalid UTF-8 sequence
|
||||
let data: Vec<u8> = vec![0xFF, 0xFE, 0x00, 0x01];
|
||||
|
||||
let (svc, recorder) = make_service_with_recorder(dir.path());
|
||||
let ws = dir.path().to_str().unwrap();
|
||||
|
||||
svc.write_file(file.to_str().unwrap(), &data, ws).await.unwrap();
|
||||
|
||||
let events = recorder.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let event = &events[0];
|
||||
// content should be absent for binary data (not valid UTF-8)
|
||||
assert!(
|
||||
event.data.get("content").is_none(),
|
||||
"binary write should omit content in event"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_nested_relative_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join("src/utils")).unwrap();
|
||||
let file = dir.path().join("src/utils/helper.ts");
|
||||
|
||||
let (svc, recorder) = make_service_with_recorder(dir.path());
|
||||
let ws = dir.path().to_str().unwrap();
|
||||
|
||||
svc.write_file(file.to_str().unwrap(), b"export {}", ws).await.unwrap();
|
||||
|
||||
let events = recorder.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].data["relative_path"], "src/utils/helper.ts");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// read after write (roundtrip)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_after_write_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("roundtrip.txt");
|
||||
let content = "roundtrip test content 你好";
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let ws = dir.path().to_str().unwrap();
|
||||
|
||||
// Write
|
||||
let ok = svc
|
||||
.write_file(file.to_str().unwrap(), content.as_bytes(), ws)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(ok);
|
||||
|
||||
// Read back
|
||||
let read_result = svc.read_file(file.to_str().unwrap(), None).await.unwrap();
|
||||
assert_eq!(read_result.as_deref(), Some(content));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_buffer_after_write_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("roundtrip.bin");
|
||||
let data: Vec<u8> = vec![0x01, 0x02, 0x03, 0xFF, 0xFE];
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let ws = dir.path().to_str().unwrap();
|
||||
|
||||
svc.write_file(file.to_str().unwrap(), &data, ws).await.unwrap();
|
||||
|
||||
let read_result = svc.read_file_buffer(file.to_str().unwrap(), None).await.unwrap();
|
||||
assert_eq!(read_result.as_deref(), Some(data.as_slice()));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// write_file invalidates workspace files cache
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_invalidates_cache() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("existing.txt"), "data").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(), 1);
|
||||
|
||||
// Write a new file (should invalidate cache)
|
||||
let new_file = dir.path().join("new.txt");
|
||||
svc.write_file(new_file.to_str().unwrap(), b"new", ws).await.unwrap();
|
||||
|
||||
// Cache should be invalidated, so we see the new file
|
||||
let files = svc.list_workspace_files(ws).await.unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Integration tests for file watching (task 7.8).
|
||||
//!
|
||||
//! Tests exercise `IFileWatchService` through `FileWatchService`, verifying
|
||||
//! that filesystem changes produce the expected broadcast events.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_file::{FileWatchService, IFileWatchService};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// A broadcaster that records every event for later assertion.
|
||||
struct RecordingBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl RecordingBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain all recorded events.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_service() -> (Arc<dyn IFileWatchService>, Arc<RecordingBroadcaster>) {
|
||||
let recorder = Arc::new(RecordingBroadcaster::new());
|
||||
let svc = FileWatchService::new(recorder.clone()).unwrap();
|
||||
(Arc::new(svc), recorder)
|
||||
}
|
||||
|
||||
/// Wait a bit for the OS file-system event to propagate and the watcher
|
||||
/// callback to fire. File-system notifications are inherently asynchronous.
|
||||
async fn settle() {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Single-file watching
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_watch_and_detect_change() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("watched.txt");
|
||||
std::fs::write(&file, "initial").unwrap();
|
||||
|
||||
let (svc, recorder) = make_service();
|
||||
svc.start_watch(file.to_str().unwrap()).await.unwrap();
|
||||
|
||||
// Modify the file.
|
||||
settle().await;
|
||||
std::fs::write(&file, "updated").unwrap();
|
||||
settle().await;
|
||||
|
||||
let events = recorder.take_events();
|
||||
assert!(
|
||||
events.iter().any(|e| e.name == "fileWatch.fileChanged"),
|
||||
"expected fileWatch.fileChanged event, got: {events:?}"
|
||||
);
|
||||
|
||||
let ev = events.iter().find(|e| e.name == "fileWatch.fileChanged").unwrap();
|
||||
assert!(ev.data["file_path"].as_str().is_some());
|
||||
assert!(ev.data["event_type"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_watch_stops_events() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("stop_me.txt");
|
||||
std::fs::write(&file, "v1").unwrap();
|
||||
|
||||
let (svc, recorder) = make_service();
|
||||
let path_str = file.to_str().unwrap();
|
||||
svc.start_watch(path_str).await.unwrap();
|
||||
settle().await;
|
||||
|
||||
svc.stop_watch(path_str).await.unwrap();
|
||||
// Drain any events from the watch setup.
|
||||
recorder.take_events();
|
||||
|
||||
// Modify after stop — should NOT produce events.
|
||||
std::fs::write(&file, "v2").unwrap();
|
||||
settle().await;
|
||||
|
||||
let events = recorder.take_events();
|
||||
assert!(events.is_empty(), "expected no events after stop, got: {events:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_all_watches_clears_file_watches() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_a = dir.path().join("a.txt");
|
||||
let file_b = dir.path().join("b.txt");
|
||||
std::fs::write(&file_a, "a").unwrap();
|
||||
std::fs::write(&file_b, "b").unwrap();
|
||||
|
||||
let (svc, recorder) = make_service();
|
||||
svc.start_watch(file_a.to_str().unwrap()).await.unwrap();
|
||||
svc.start_watch(file_b.to_str().unwrap()).await.unwrap();
|
||||
settle().await;
|
||||
|
||||
svc.stop_all_watches().await.unwrap();
|
||||
recorder.take_events();
|
||||
|
||||
std::fs::write(&file_a, "a2").unwrap();
|
||||
std::fs::write(&file_b, "b2").unwrap();
|
||||
settle().await;
|
||||
|
||||
let events = recorder.take_events();
|
||||
assert!(events.is_empty(), "expected no events after stop_all, got: {events:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idempotent_start_watch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("idem.txt");
|
||||
std::fs::write(&file, "x").unwrap();
|
||||
|
||||
let (svc, _recorder) = make_service();
|
||||
let path_str = file.to_str().unwrap();
|
||||
svc.start_watch(path_str).await.unwrap();
|
||||
// Second start should be a no-op, not an error.
|
||||
svc.start_watch(path_str).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watch_nonexistent_file_returns_error() {
|
||||
let (svc, _recorder) = make_service();
|
||||
let result = svc.start_watch("/tmp/nonexistent_12345.txt").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Workspace Office file watching
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn office_watch_detects_docx() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (svc, recorder) = make_service();
|
||||
svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap();
|
||||
settle().await;
|
||||
|
||||
// Create a .docx file.
|
||||
std::fs::write(dir.path().join("report.docx"), "fake docx").unwrap();
|
||||
settle().await;
|
||||
|
||||
let events = recorder.take_events();
|
||||
assert!(
|
||||
events.iter().any(|e| e.name == "workspaceOfficeWatch.fileAdded"),
|
||||
"expected workspaceOfficeWatch.fileAdded event, got: {events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn office_watch_detects_xlsx() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (svc, recorder) = make_service();
|
||||
svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap();
|
||||
settle().await;
|
||||
|
||||
std::fs::write(dir.path().join("data.xlsx"), "fake xlsx").unwrap();
|
||||
settle().await;
|
||||
|
||||
let events = recorder.take_events();
|
||||
assert!(
|
||||
events.iter().any(|e| e.name == "workspaceOfficeWatch.fileAdded"),
|
||||
"expected fileAdded for .xlsx, got: {events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn office_watch_detects_pptx() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (svc, recorder) = make_service();
|
||||
svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap();
|
||||
settle().await;
|
||||
|
||||
std::fs::write(dir.path().join("slides.pptx"), "fake pptx").unwrap();
|
||||
settle().await;
|
||||
|
||||
let events = recorder.take_events();
|
||||
assert!(
|
||||
events.iter().any(|e| e.name == "workspaceOfficeWatch.fileAdded"),
|
||||
"expected fileAdded for .pptx, got: {events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn office_watch_ignores_non_office_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (svc, recorder) = make_service();
|
||||
svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap();
|
||||
settle().await;
|
||||
|
||||
// Drain any setup events.
|
||||
recorder.take_events();
|
||||
|
||||
// Create a non-Office file — should NOT trigger.
|
||||
std::fs::write(dir.path().join("notes.txt"), "hello").unwrap();
|
||||
settle().await;
|
||||
|
||||
let events = recorder.take_events();
|
||||
let office_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.name == "workspaceOfficeWatch.fileAdded")
|
||||
.collect();
|
||||
assert!(
|
||||
office_events.is_empty(),
|
||||
"expected no office events for .txt, got: {office_events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_office_watch_stops_events() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (svc, recorder) = make_service();
|
||||
let ws = dir.path().to_str().unwrap();
|
||||
svc.start_office_watch(ws).await.unwrap();
|
||||
settle().await;
|
||||
|
||||
svc.stop_office_watch(ws).await.unwrap();
|
||||
recorder.take_events();
|
||||
|
||||
std::fs::write(dir.path().join("after_stop.docx"), "data").unwrap();
|
||||
settle().await;
|
||||
|
||||
let events = recorder.take_events();
|
||||
let office_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.name == "workspaceOfficeWatch.fileAdded")
|
||||
.collect();
|
||||
assert!(
|
||||
office_events.is_empty(),
|
||||
"expected no events after stop, got: {office_events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idempotent_office_watch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (svc, _recorder) = make_service();
|
||||
let ws = dir.path().to_str().unwrap();
|
||||
svc.start_office_watch(ws).await.unwrap();
|
||||
// Second call should be a no-op.
|
||||
svc.start_office_watch(ws).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn office_watch_event_has_correct_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (svc, recorder) = make_service();
|
||||
svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap();
|
||||
settle().await;
|
||||
|
||||
std::fs::write(dir.path().join("check.docx"), "content").unwrap();
|
||||
settle().await;
|
||||
|
||||
let events = recorder.take_events();
|
||||
let ev = events.iter().find(|e| e.name == "workspaceOfficeWatch.fileAdded");
|
||||
assert!(ev.is_some(), "expected fileAdded event, got: {events:?}");
|
||||
|
||||
let data = &ev.unwrap().data;
|
||||
assert!(
|
||||
data["file_path"].as_str().is_some_and(|p| p.ends_with("check.docx")),
|
||||
"file_path should end with check.docx: {data:?}"
|
||||
);
|
||||
assert!(
|
||||
data["workspace"].as_str().is_some(),
|
||||
"workspace should be present: {data:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Integration tests for image processing operations (task 7.6).
|
||||
//!
|
||||
//! These tests exercise `get_image_base64` and `fetch_remote_image`
|
||||
//! through the `IFileService` trait, covering local image encoding,
|
||||
//! remote image fetching with whitelist/protocol/size validation,
|
||||
//! and placeholder SVG fallback behavior.
|
||||
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_file::{FileService, IFileService};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
|
||||
/// No-op broadcaster for tests that don't need event verification.
|
||||
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()])
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getImageBase64 — test-plan 4.1
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_image_base64_png() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.png");
|
||||
// Minimal valid-looking PNG bytes (magic header)
|
||||
let png_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
fs::write(&file, &png_bytes).unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.get_image_base64(file.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert!(
|
||||
result.starts_with("data:image/png;base64,"),
|
||||
"expected data:image/png;base64, prefix, got: {}",
|
||||
&result[..50.min(result.len())]
|
||||
);
|
||||
|
||||
// Verify roundtrip: decode base64 back to original bytes
|
||||
let encoded_part = result.strip_prefix("data:image/png;base64,").unwrap();
|
||||
let decoded = base64::engine::general_purpose::STANDARD.decode(encoded_part).unwrap();
|
||||
assert_eq!(decoded, png_bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_image_base64_jpeg() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("photo.jpg");
|
||||
let jpeg_bytes = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
|
||||
fs::write(&file, &jpeg_bytes).unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.get_image_base64(file.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert!(result.starts_with("data:image/jpeg;base64,"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_image_base64_svg() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("icon.svg");
|
||||
let svg_content =
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><circle cx="50" cy="50" r="40"/></svg>"#;
|
||||
fs::write(&file, svg_content).unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.get_image_base64(file.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert!(result.starts_with("data:image/svg+xml;base64,"));
|
||||
|
||||
// Verify content roundtrip
|
||||
let encoded_part = result.strip_prefix("data:image/svg+xml;base64,").unwrap();
|
||||
let decoded = base64::engine::general_purpose::STANDARD.decode(encoded_part).unwrap();
|
||||
assert_eq!(String::from_utf8(decoded).unwrap(), svg_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_image_base64_nonexistent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc
|
||||
.get_image_base64(dir.path().join("missing.png").to_str().unwrap(), None)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_base64_with_extra_workspace_root() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let file = workspace.path().join("test.png");
|
||||
let png_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
fs::write(&file, &png_bytes).unwrap();
|
||||
|
||||
let svc = make_service(sandbox.path());
|
||||
let result = svc
|
||||
.get_image_base64(file.to_str().unwrap(), Some(workspace.path()))
|
||||
.await;
|
||||
|
||||
assert!(result.unwrap().starts_with("data:image/png;base64,"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_image_base64_gif() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("animation.gif");
|
||||
let gif_bytes = b"GIF89a\x01\x00\x01\x00\x80\x00\x00";
|
||||
fs::write(&file, gif_bytes).unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.get_image_base64(file.to_str().unwrap(), None).await.unwrap();
|
||||
|
||||
assert!(result.starts_with("data:image/gif;base64,"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_image_base64_path_traversal_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
let result = svc.get_image_base64("../../etc/passwd", None).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_image_base64_outside_sandbox_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
// /tmp exists but is outside the sandbox (dir.path())
|
||||
let result = svc.get_image_base64("/etc/hosts", None).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// fetchRemoteImage — test-plan 4.2
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_remote_image_disallowed_host_returns_placeholder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let result = svc.fetch_remote_image("https://evil.com/image.png").await;
|
||||
|
||||
assert!(
|
||||
result.starts_with("data:image/svg+xml;base64,"),
|
||||
"expected placeholder SVG, got: {}",
|
||||
&result[..60.min(result.len())]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_remote_image_ftp_protocol_returns_placeholder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let result = svc.fetch_remote_image("ftp://github.com/image.png").await;
|
||||
|
||||
assert!(result.starts_with("data:image/svg+xml;base64,"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_remote_image_invalid_url_returns_placeholder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let result = svc.fetch_remote_image("not-a-url").await;
|
||||
|
||||
assert!(result.starts_with("data:image/svg+xml;base64,"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_remote_image_file_protocol_returns_placeholder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let result = svc.fetch_remote_image("file:///etc/passwd").await;
|
||||
|
||||
assert!(result.starts_with("data:image/svg+xml;base64,"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_remote_image_empty_url_returns_placeholder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let result = svc.fetch_remote_image("").await;
|
||||
|
||||
assert!(result.starts_with("data:image/svg+xml;base64,"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_remote_image_placeholder_contains_valid_svg() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let result = svc.fetch_remote_image("not-valid").await;
|
||||
|
||||
// Verify the placeholder decodes to a valid SVG
|
||||
let encoded_part = result.strip_prefix("data:image/svg+xml;base64,").unwrap();
|
||||
let decoded = base64::engine::general_purpose::STANDARD.decode(encoded_part).unwrap();
|
||||
let svg = String::from_utf8(decoded).unwrap();
|
||||
assert!(svg.contains("<svg"));
|
||||
assert!(svg.contains("</svg>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_remote_image_data_protocol_returns_placeholder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let result = svc.fetch_remote_image("data:text/html,<script>alert(1)</script>").await;
|
||||
|
||||
assert!(result.starts_with("data:image/svg+xml;base64,"));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,363 @@
|
||||
//! Integration tests for ZIP packaging operations (task 7.7).
|
||||
//!
|
||||
//! These tests exercise `create_zip` and `cancel_zip` through the
|
||||
//! `IFileService` trait, covering text content packaging, disk file
|
||||
//! packaging, mixed entries, cancellation, sandbox validation,
|
||||
//! and archive verification.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_file::{FileService, IFileService, ZipEntry};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
|
||||
/// No-op broadcaster for tests that don't need event verification.
|
||||
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()])
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// create_zip — test-plan 5.1
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_zip_text_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let zip_path = dir.path().join("text.zip");
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let entries = vec![
|
||||
ZipEntry::Text {
|
||||
name: "a.txt".into(),
|
||||
content: "hello".into(),
|
||||
},
|
||||
ZipEntry::Text {
|
||||
name: "dir/b.txt".into(),
|
||||
content: "world".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await.unwrap();
|
||||
assert!(result);
|
||||
|
||||
// Verify the ZIP can be opened and contains correct data
|
||||
let file = fs::File::open(&zip_path).unwrap();
|
||||
let mut archive = zip::ZipArchive::new(file).unwrap();
|
||||
assert_eq!(archive.len(), 2);
|
||||
|
||||
{
|
||||
let mut entry = archive.by_name("a.txt").unwrap();
|
||||
let mut buf = String::new();
|
||||
entry.read_to_string(&mut buf).unwrap();
|
||||
assert_eq!(buf, "hello");
|
||||
}
|
||||
{
|
||||
let mut entry = archive.by_name("dir/b.txt").unwrap();
|
||||
let mut buf = String::new();
|
||||
entry.read_to_string(&mut buf).unwrap();
|
||||
assert_eq!(buf, "world");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_zip_disk_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let src_a = dir.path().join("src_a.txt");
|
||||
let src_b = dir.path().join("src_b.bin");
|
||||
fs::write(&src_a, "file A content").unwrap();
|
||||
fs::write(&src_b, b"\x00\x01\x02\x03").unwrap();
|
||||
|
||||
let zip_path = dir.path().join("disk.zip");
|
||||
let svc = make_service(dir.path());
|
||||
let entries = vec![
|
||||
ZipEntry::Disk {
|
||||
name: "a.txt".into(),
|
||||
file_path: src_a.to_string_lossy().into_owned(),
|
||||
},
|
||||
ZipEntry::Disk {
|
||||
name: "b.bin".into(),
|
||||
file_path: src_b.to_string_lossy().into_owned(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await.unwrap();
|
||||
assert!(result);
|
||||
|
||||
let file = fs::File::open(&zip_path).unwrap();
|
||||
let mut archive = zip::ZipArchive::new(file).unwrap();
|
||||
assert_eq!(archive.len(), 2);
|
||||
|
||||
{
|
||||
let mut entry = archive.by_name("a.txt").unwrap();
|
||||
let mut buf = String::new();
|
||||
entry.read_to_string(&mut buf).unwrap();
|
||||
assert_eq!(buf, "file A content");
|
||||
}
|
||||
{
|
||||
let mut entry = archive.by_name("b.bin").unwrap();
|
||||
let mut buf = Vec::new();
|
||||
entry.read_to_end(&mut buf).unwrap();
|
||||
assert_eq!(buf, b"\x00\x01\x02\x03");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_zip_mixed_content_and_disk() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let src = dir.path().join("real.txt");
|
||||
fs::write(&src, "real file").unwrap();
|
||||
|
||||
let zip_path = dir.path().join("mixed.zip");
|
||||
let svc = make_service(dir.path());
|
||||
let entries = vec![
|
||||
ZipEntry::Text {
|
||||
name: "virtual.txt".into(),
|
||||
content: "in-memory".into(),
|
||||
},
|
||||
ZipEntry::Disk {
|
||||
name: "real.txt".into(),
|
||||
file_path: src.to_string_lossy().into_owned(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await.unwrap();
|
||||
assert!(result);
|
||||
|
||||
let file = fs::File::open(&zip_path).unwrap();
|
||||
let mut archive = zip::ZipArchive::new(file).unwrap();
|
||||
assert_eq!(archive.len(), 2);
|
||||
|
||||
{
|
||||
let mut entry = archive.by_name("virtual.txt").unwrap();
|
||||
let mut buf = String::new();
|
||||
entry.read_to_string(&mut buf).unwrap();
|
||||
assert_eq!(buf, "in-memory");
|
||||
}
|
||||
{
|
||||
let mut entry = archive.by_name("real.txt").unwrap();
|
||||
let mut buf = String::new();
|
||||
entry.read_to_string(&mut buf).unwrap();
|
||||
assert_eq!(buf, "real file");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_zip_with_request_id() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let zip_path = dir.path().join("req.zip");
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let entries = vec![ZipEntry::Text {
|
||||
name: "data.txt".into(),
|
||||
content: "test data".into(),
|
||||
}];
|
||||
|
||||
let result = svc
|
||||
.create_zip(zip_path.to_str().unwrap(), entries, Some("req-123".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result);
|
||||
assert!(zip_path.exists());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// cancel_zip — test-plan 5.2
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_zip_nonexistent_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
// Cancelling a request that doesn't exist returns false
|
||||
let result = svc.cancel_zip("no-such-id").await;
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_zip_completed_request_returns_false() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let zip_path = dir.path().join("done.zip");
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let entries = vec![ZipEntry::Text {
|
||||
name: "a.txt".into(),
|
||||
content: "data".into(),
|
||||
}];
|
||||
|
||||
// Complete the ZIP first
|
||||
svc.create_zip(zip_path.to_str().unwrap(), entries, Some("req-done".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// After completion, the token is cleaned up — cancel returns false
|
||||
let result = svc.cancel_zip("req-done").await;
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Error cases
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_zip_disk_entry_nonexistent_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let zip_path = dir.path().join("fail.zip");
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let entries = vec![ZipEntry::Disk {
|
||||
name: "missing.txt".into(),
|
||||
file_path: "/nonexistent/path/file.txt".into(),
|
||||
}];
|
||||
|
||||
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_zip_empty_entries_produces_valid_archive() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let zip_path = dir.path().join("empty.zip");
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
let result = svc.create_zip(zip_path.to_str().unwrap(), vec![], None).await.unwrap();
|
||||
assert!(result);
|
||||
|
||||
let file = fs::File::open(&zip_path).unwrap();
|
||||
let archive = zip::ZipArchive::new(file).unwrap();
|
||||
assert_eq!(archive.len(), 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Sandbox validation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_zip_rejects_output_outside_sandbox() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
// Output path is outside the allowed root
|
||||
let zip_path = other.path().join("escape.zip");
|
||||
let entries = vec![ZipEntry::Text {
|
||||
name: "a.txt".into(),
|
||||
content: "data".into(),
|
||||
}];
|
||||
|
||||
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(!zip_path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_zip_rejects_disk_entry_outside_sandbox() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
let outside_file = other.path().join("secret.txt");
|
||||
fs::write(&outside_file, "sensitive data").unwrap();
|
||||
|
||||
let svc = make_service(dir.path());
|
||||
let zip_path = dir.path().join("steal.zip");
|
||||
let entries = vec![ZipEntry::Disk {
|
||||
name: "stolen.txt".into(),
|
||||
file_path: outside_file.to_string_lossy().into_owned(),
|
||||
}];
|
||||
|
||||
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(!zip_path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_zip_rejects_nonexistent_disk_entry_in_sandbox() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let zip_path = dir.path().join("fail.zip");
|
||||
let svc = make_service(dir.path());
|
||||
|
||||
// Disk entry points to a non-existent file inside the sandbox —
|
||||
// validate_path rejects it before any ZIP is created.
|
||||
let entries = vec![ZipEntry::Disk {
|
||||
name: "missing.txt".into(),
|
||||
file_path: dir.path().join("no_such.txt").to_string_lossy().into_owned(),
|
||||
}];
|
||||
|
||||
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(!zip_path.exists());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// cancel_zip — in-progress cancellation (test-plan 5.2)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_zip_in_progress() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// Create many small source files to give time for cancellation
|
||||
let src_dir = dir.path().join("sources");
|
||||
fs::create_dir(&src_dir).unwrap();
|
||||
let entry_count = 500;
|
||||
let mut entries = Vec::with_capacity(entry_count);
|
||||
for i in 0..entry_count {
|
||||
let name = format!("file_{i:04}.txt");
|
||||
let path = src_dir.join(&name);
|
||||
fs::write(&path, format!("content of file {i}")).unwrap();
|
||||
entries.push(ZipEntry::Disk {
|
||||
name,
|
||||
file_path: path.to_string_lossy().into_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let zip_path = dir.path().join("big.zip");
|
||||
let svc = Arc::new(make_service(dir.path()));
|
||||
let svc_cancel = Arc::clone(&svc);
|
||||
|
||||
let zip_path_str = zip_path.to_string_lossy().into_owned();
|
||||
let request_id = "cancel-me".to_owned();
|
||||
|
||||
// Spawn ZIP creation in a separate task
|
||||
let create_handle = tokio::spawn({
|
||||
let request_id = request_id.clone();
|
||||
async move { svc.create_zip(&zip_path_str, entries, Some(request_id)).await }
|
||||
});
|
||||
|
||||
// Give a brief moment for the creation to start, then cancel
|
||||
tokio::task::yield_now().await;
|
||||
let cancelled = svc_cancel.cancel_zip(&request_id).await;
|
||||
|
||||
let result = create_handle.await.unwrap();
|
||||
|
||||
// Either the cancel signal was picked up (Ok(false)) or it completed
|
||||
// before the signal arrived (Ok(true)). The key assertion is that
|
||||
// cancel_zip returned true (it found the token).
|
||||
if cancelled {
|
||||
// cancel_zip found and set the flag
|
||||
match result {
|
||||
Ok(false) => {
|
||||
// Successfully cancelled — partial file should be removed
|
||||
assert!(
|
||||
!zip_path.exists(),
|
||||
"partial ZIP should be cleaned up after cancellation"
|
||||
);
|
||||
}
|
||||
Ok(true) => {
|
||||
// ZIP completed before cancellation took effect — file exists
|
||||
assert!(zip_path.exists());
|
||||
}
|
||||
Err(e) => panic!("unexpected error: {e}"),
|
||||
}
|
||||
} else {
|
||||
// Token was already cleaned up, meaning create_zip finished first
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user