Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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());
}
}