From 12ea72c6d4e19afb73661a27110191418cb4e86f Mon Sep 17 00:00:00 2001 From: ManhND Date: Tue, 23 Jun 2026 23:34:17 +0000 Subject: [PATCH] Add 77 unit tests for 6 previously untested modules Add comprehensive test coverage for modules that had zero tests: - app_paths.rs (26 tests): normalize/build/find functions for Codex app paths, version extraction, macOS/Windows package resolution, user data candidates - user_scripts.rs (17 tests): UserScriptManager config CRUD, script enable/disable, delete with path traversal protection, inventory, bundle building, market filename - script_market.rs (11 tests): manifest parsing, field validation, missing/empty field handling, tag filtering, install_market_script_content - diagnostic_log.rs (6 tests): append/read log entries, path override, directory creation, serialized via mutex to avoid global state races - codex_sqlite.rs (10 tests): sidecar paths, relative path resolution, session DB discovery with sqlite dir vs legacy fallback, extension filtering - backup.rs (7 tests): write/read roundtrip, directory creation, token sanitization, path traversal prevention, unique token generation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/codex-plus-core/tests/app_paths.rs | 274 ++++++++++++++++ crates/codex-plus-core/tests/codex_sqlite.rs | 148 +++++++++ .../codex-plus-core/tests/diagnostic_log.rs | 100 ++++++ crates/codex-plus-core/tests/script_market.rs | 227 ++++++++++++++ crates/codex-plus-core/tests/user_scripts.rs | 292 ++++++++++++++++++ crates/codex-plus-data/tests/backup.rs | 109 +++++++ 6 files changed, 1150 insertions(+) create mode 100644 crates/codex-plus-core/tests/app_paths.rs create mode 100644 crates/codex-plus-core/tests/codex_sqlite.rs create mode 100644 crates/codex-plus-core/tests/diagnostic_log.rs create mode 100644 crates/codex-plus-core/tests/script_market.rs create mode 100644 crates/codex-plus-core/tests/user_scripts.rs create mode 100644 crates/codex-plus-data/tests/backup.rs diff --git a/crates/codex-plus-core/tests/app_paths.rs b/crates/codex-plus-core/tests/app_paths.rs new file mode 100644 index 000000000..42845ffb4 --- /dev/null +++ b/crates/codex-plus-core/tests/app_paths.rs @@ -0,0 +1,274 @@ +use std::path::{Path, PathBuf}; + +use codex_plus_core::app_paths; + +#[test] +fn normalize_codex_app_path_returns_none_for_empty_path() { + assert!(app_paths::normalize_codex_app_path(Path::new("")).is_none()); +} + +#[test] +fn normalize_codex_app_path_strips_codex_exe_filename() { + let path = Path::new("/some/dir/Codex.exe"); + assert_eq!( + app_paths::normalize_codex_app_path(path), + Some(PathBuf::from("/some/dir")) + ); + + let lower = Path::new("/some/dir/codex.exe"); + assert_eq!( + app_paths::normalize_codex_app_path(lower), + Some(PathBuf::from("/some/dir")) + ); +} + +#[test] +fn normalize_codex_app_path_keeps_dot_app_extension() { + let path = Path::new("/Applications/Codex.app"); + assert_eq!( + app_paths::normalize_codex_app_path(path), + Some(PathBuf::from("/Applications/Codex.app")) + ); +} + +#[test] +fn normalize_codex_app_path_returns_dir_when_exists() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path().join("codex-app"); + std::fs::create_dir_all(&dir).unwrap(); + + assert_eq!(app_paths::normalize_codex_app_path(&dir), Some(dir.clone())); +} + +#[test] +fn build_codex_executable_adds_exe_for_directory() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path().join("app"); + std::fs::create_dir_all(&dir).unwrap(); + + let exe = app_paths::build_codex_executable(&dir); + assert_eq!(exe, dir.join("codex.exe")); +} + +#[test] +fn build_codex_executable_uses_macos_path_for_dot_app() { + let path = Path::new("/Applications/Codex.app"); + let exe = app_paths::build_codex_executable(path); + assert_eq!( + exe, + PathBuf::from("/Applications/Codex.app/Contents/MacOS/Codex") + ); +} + +#[test] +fn build_codex_executable_prefers_uppercase_when_exists() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path().join("app"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Codex.exe"), b"").unwrap(); + + let exe = app_paths::build_codex_executable(&dir); + assert_eq!(exe, dir.join("Codex.exe")); +} + +#[test] +fn find_latest_codex_app_dir_picks_highest_version() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + + let v1 = root.join("OpenAI.Codex_1.0.0_x64__abc123"); + let v2 = root.join("OpenAI.Codex_2.3.1_x64__abc123"); + let v0 = root.join("OpenAI.Codex_0.5.0_x64__abc123"); + + for dir in [&v1, &v2, &v0] { + std::fs::create_dir_all(dir).unwrap(); + } + + let result = app_paths::find_latest_codex_app_dir(root).unwrap(); + assert_eq!(result, v2); +} + +#[test] +fn find_latest_codex_app_dir_uses_nested_app_dir_when_present() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + + let pkg = root.join("OpenAI.Codex_1.0.0_x64__abc123"); + let app_dir = pkg.join("app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + let result = app_paths::find_latest_codex_app_dir(root).unwrap(); + assert_eq!(result, app_dir); +} + +#[test] +fn find_latest_codex_app_dir_returns_none_for_empty_root() { + let temp = tempfile::tempdir().unwrap(); + assert!(app_paths::find_latest_codex_app_dir(temp.path()).is_none()); +} + +#[test] +fn find_latest_codex_app_dir_returns_none_for_missing_root() { + assert!(app_paths::find_latest_codex_app_dir(Path::new("/nonexistent/path")).is_none()); +} + +#[test] +fn find_latest_codex_app_dir_from_roots_picks_highest_across_roots() { + let temp = tempfile::tempdir().unwrap(); + let root1 = temp.path().join("root1"); + let root2 = temp.path().join("root2"); + + let v1 = root1.join("OpenAI.Codex_1.0.0_x64__abc123"); + let v2 = root2.join("OpenAI.Codex_3.0.0_x64__abc123"); + + for dir in [&v1, &v2] { + std::fs::create_dir_all(dir).unwrap(); + } + + let result = app_paths::find_latest_codex_app_dir_from_roots(&[root1, root2]).unwrap(); + assert_eq!(result, v2); +} + +#[test] +fn user_data_candidates_from_appends_expected_variants() { + let local = Path::new("/Users/test/AppData/Local"); + let roaming = Path::new("/Users/test/AppData/Roaming"); + + let candidates = app_paths::user_data_candidates_from(Some(local), Some(roaming)); + + assert!(candidates.contains(&local.join("OpenAI").join("Codex"))); + assert!(candidates.contains(&local.join("OpenAI.Codex"))); + assert!(candidates.contains(&local.join("Codex"))); + assert!(candidates.contains(&roaming.join("OpenAI").join("Codex"))); + assert_eq!(candidates.len(), 6); +} + +#[test] +fn user_data_candidates_from_handles_none_inputs() { + assert!(app_paths::user_data_candidates_from(None, None).is_empty()); + + let local = Path::new("/local"); + let candidates = app_paths::user_data_candidates_from(Some(local), None); + assert_eq!(candidates.len(), 3); +} + +#[test] +fn latest_appx_install_location_from_output_extracts_first_non_empty_line() { + let output = "\n C:\\Program Files\\WindowsApps\\OpenAI.Codex_1.0.0 \n\n"; + assert_eq!( + app_paths::latest_appx_install_location_from_output(output), + Some("C:\\Program Files\\WindowsApps\\OpenAI.Codex_1.0.0".to_string()) + ); +} + +#[test] +fn latest_appx_install_location_from_output_returns_none_for_empty() { + assert!(app_paths::latest_appx_install_location_from_output("").is_none()); + assert!(app_paths::latest_appx_install_location_from_output(" \n \n").is_none()); +} + +#[test] +fn codex_app_version_extracts_version_from_package_dir() { + let temp = tempfile::tempdir().unwrap(); + let pkg = temp.path().join("OpenAI.Codex_2.5.10_x64__abc123"); + let app_dir = pkg.join("app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + assert_eq!( + app_paths::codex_app_version(&app_dir), + Some("2.5.10".to_string()) + ); +} + +#[test] +fn codex_app_version_reads_macos_plist() { + let temp = tempfile::tempdir().unwrap(); + let app = temp.path().join("Codex.app"); + let contents_dir = app.join("Contents"); + std::fs::create_dir_all(&contents_dir).unwrap(); + std::fs::write( + contents_dir.join("Info.plist"), + r#" + + + CFBundleShortVersionString + 3.1.4 + +"#, + ) + .unwrap(); + + assert_eq!( + app_paths::codex_app_version(&app), + Some("3.1.4".to_string()) + ); +} + +#[test] +fn codex_app_version_returns_none_for_non_package_dir() { + let temp = tempfile::tempdir().unwrap(); + assert!(app_paths::codex_app_version(temp.path()).is_none()); +} + +#[test] +fn packaged_app_user_model_id_builds_expected_format() { + let path = Path::new("/WindowsApps/OpenAI.Codex_2.5.0_x64__abc123def/app"); + assert_eq!( + app_paths::packaged_app_user_model_id(path), + Some("OpenAI.Codex_abc123def!App".to_string()) + ); +} + +#[test] +fn packaged_app_user_model_id_returns_none_for_non_package_path() { + let path = Path::new("/some/random/path"); + assert!(app_paths::packaged_app_user_model_id(path).is_none()); +} + +#[test] +fn find_macos_codex_app_finds_app_in_search_root() { + let temp = tempfile::tempdir().unwrap(); + let app_dir = temp.path().join("Codex.app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + let result = app_paths::find_macos_codex_app(&[temp.path().to_path_buf()]); + assert_eq!(result, Some(app_dir)); +} + +#[test] +fn find_macos_codex_app_finds_alternative_names() { + let temp = tempfile::tempdir().unwrap(); + let app_dir = temp.path().join("OpenAI Codex.app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + let result = app_paths::find_macos_codex_app(&[temp.path().to_path_buf()]); + assert_eq!(result, Some(app_dir)); +} + +#[test] +fn find_macos_codex_app_returns_none_when_not_found() { + let temp = tempfile::tempdir().unwrap(); + assert!(app_paths::find_macos_codex_app(&[temp.path().to_path_buf()]).is_none()); +} + +#[test] +fn find_macos_codex_app_accepts_direct_app_path() { + let temp = tempfile::tempdir().unwrap(); + let app_dir = temp.path().join("CustomCodex.app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + let result = app_paths::find_macos_codex_app(&[app_dir.clone()]); + assert_eq!(result, Some(app_dir)); +} + +#[test] +fn codex_beta_package_is_also_recognized() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + + let beta = root.join("OpenAI.CodexBeta_1.0.0_x64__beta123"); + std::fs::create_dir_all(&beta).unwrap(); + + let result = app_paths::find_latest_codex_app_dir(root).unwrap(); + assert_eq!(result, beta); +} diff --git a/crates/codex-plus-core/tests/codex_sqlite.rs b/crates/codex-plus-core/tests/codex_sqlite.rs new file mode 100644 index 000000000..9958116cf --- /dev/null +++ b/crates/codex-plus-core/tests/codex_sqlite.rs @@ -0,0 +1,148 @@ +use std::path::Path; + +use codex_plus_core::codex_sqlite; + +#[test] +fn codex_sqlite_sidecar_paths_returns_db_wal_shm() { + let db = Path::new("/home/user/.codex/sqlite/codex-dev.db"); + let paths = codex_sqlite::codex_sqlite_sidecar_paths(db); + + assert_eq!(paths[0], db); + assert_eq!( + paths[1].to_string_lossy(), + "/home/user/.codex/sqlite/codex-dev.db-wal" + ); + assert_eq!( + paths[2].to_string_lossy(), + "/home/user/.codex/sqlite/codex-dev.db-shm" + ); +} + +#[test] +fn relative_to_codex_home_strips_home_prefix() { + let home = Path::new("/home/user/.codex"); + let path = Path::new("/home/user/.codex/sqlite/state.db"); + let relative = codex_sqlite::relative_to_codex_home(home, path); + assert_eq!(relative, Path::new("sqlite/state.db")); +} + +#[test] +fn relative_to_codex_home_returns_full_path_when_not_under_home() { + let home = Path::new("/home/user/.codex"); + let path = Path::new("/other/location/state.db"); + let relative = codex_sqlite::relative_to_codex_home(home, path); + assert_eq!(relative, path); +} + +#[test] +fn codex_session_db_path_from_home_falls_back_to_legacy_path() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + + let db_path = codex_sqlite::codex_session_db_path_from_home(home); + assert_eq!(db_path, home.join("state_5.sqlite")); +} + +#[test] +fn codex_session_db_path_from_home_prefers_sqlite_dir() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let sqlite_dir = home.join("sqlite"); + std::fs::create_dir_all(&sqlite_dir).unwrap(); + + let db = sqlite_dir.join("codex-dev.db"); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute_batch("CREATE TABLE threads (id TEXT PRIMARY KEY);") + .unwrap(); + drop(conn); + + let db_path = codex_sqlite::codex_session_db_path_from_home(home); + assert_eq!(db_path, db); +} + +#[test] +fn codex_session_db_paths_from_home_includes_both_sqlite_dir_and_legacy() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let sqlite_dir = home.join("sqlite"); + std::fs::create_dir_all(&sqlite_dir).unwrap(); + + let db = sqlite_dir.join("codex-dev.db"); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute_batch("CREATE TABLE threads (id TEXT PRIMARY KEY);") + .unwrap(); + drop(conn); + + let paths = codex_sqlite::codex_session_db_paths_from_home(home); + + assert!(paths.contains(&db)); + assert!(paths.contains(&home.join("state_5.sqlite"))); +} + +#[test] +fn codex_session_db_path_from_home_skips_non_sqlite_files() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let sqlite_dir = home.join("sqlite"); + std::fs::create_dir_all(&sqlite_dir).unwrap(); + + std::fs::write(sqlite_dir.join("readme.txt"), "not a database").unwrap(); + std::fs::write(sqlite_dir.join("data.json"), "{}").unwrap(); + + let db_path = codex_sqlite::codex_session_db_path_from_home(home); + assert_eq!(db_path, home.join("state_5.sqlite")); +} + +#[test] +fn codex_session_db_path_from_home_recognizes_sqlite3_extension() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let sqlite_dir = home.join("sqlite"); + std::fs::create_dir_all(&sqlite_dir).unwrap(); + + let db = sqlite_dir.join("state.sqlite3"); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute_batch("CREATE TABLE automation_runs (id TEXT PRIMARY KEY);") + .unwrap(); + drop(conn); + + let db_path = codex_sqlite::codex_session_db_path_from_home(home); + assert_eq!(db_path, db); +} + +#[test] +fn codex_session_db_path_from_home_skips_db_without_session_tables() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let sqlite_dir = home.join("sqlite"); + std::fs::create_dir_all(&sqlite_dir).unwrap(); + + let db = sqlite_dir.join("other.db"); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute_batch("CREATE TABLE unrelated (id TEXT PRIMARY KEY);") + .unwrap(); + drop(conn); + + let db_path = codex_sqlite::codex_session_db_path_from_home(home); + assert_eq!(db_path, home.join("state_5.sqlite")); +} + +#[test] +fn codex_session_db_paths_from_home_prefers_codex_dev_db() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let sqlite_dir = home.join("sqlite"); + std::fs::create_dir_all(&sqlite_dir).unwrap(); + + let codex_dev = sqlite_dir.join("codex-dev.db"); + let other = sqlite_dir.join("other.db"); + + for path in [&codex_dev, &other] { + let conn = rusqlite::Connection::open(path).unwrap(); + conn.execute_batch("CREATE TABLE threads (id TEXT PRIMARY KEY);") + .unwrap(); + } + + let paths = codex_sqlite::codex_session_db_paths_from_home(home); + assert_eq!(paths[0], codex_dev); +} diff --git a/crates/codex-plus-core/tests/diagnostic_log.rs b/crates/codex-plus-core/tests/diagnostic_log.rs new file mode 100644 index 000000000..188fa67a7 --- /dev/null +++ b/crates/codex-plus-core/tests/diagnostic_log.rs @@ -0,0 +1,100 @@ +use serde_json::json; +use std::path::PathBuf; +use std::sync::Mutex; + +use codex_plus_core::diagnostic_log; + +static LOG_PATH_LOCK: Mutex<()> = Mutex::new(()); + +#[test] +fn append_diagnostic_log_creates_file_and_writes_json_line() { + let _guard = LOG_PATH_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let log_path = temp.path().join("logs").join("diagnostic.jsonl"); + + diagnostic_log::set_diagnostic_log_path_for_tests(Some(log_path.clone())); + diagnostic_log::append_diagnostic_log("test.event", json!({"key": "value"})).unwrap(); + diagnostic_log::set_diagnostic_log_path_for_tests(None); + + let contents = std::fs::read_to_string(&log_path).unwrap(); + let lines: Vec<&str> = contents.lines().collect(); + assert_eq!(lines.len(), 1); + + let record: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(record["event"], "test.event"); + assert_eq!(record["detail"]["key"], "value"); + assert!(record["timestamp_ms"].as_u64().unwrap() > 0); + assert!(record["pid"].as_u64().unwrap() > 0); +} + +#[test] +fn append_diagnostic_log_appends_multiple_lines() { + let _guard = LOG_PATH_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let log_path = temp.path().join("diagnostic.jsonl"); + + diagnostic_log::set_diagnostic_log_path_for_tests(Some(log_path.clone())); + diagnostic_log::append_diagnostic_log("event.first", json!(1)).unwrap(); + diagnostic_log::append_diagnostic_log("event.second", json!(2)).unwrap(); + diagnostic_log::append_diagnostic_log("event.third", json!(3)).unwrap(); + diagnostic_log::set_diagnostic_log_path_for_tests(None); + + let contents = std::fs::read_to_string(&log_path).unwrap(); + let lines: Vec<&str> = contents.lines().collect(); + assert_eq!(lines.len(), 3); + + let r1: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + let r2: serde_json::Value = serde_json::from_str(lines[1]).unwrap(); + let r3: serde_json::Value = serde_json::from_str(lines[2]).unwrap(); + assert_eq!(r1["event"], "event.first"); + assert_eq!(r2["event"], "event.second"); + assert_eq!(r3["event"], "event.third"); +} + +#[test] +fn append_diagnostic_log_handles_string_detail() { + let _guard = LOG_PATH_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let log_path = temp.path().join("diagnostic.jsonl"); + + diagnostic_log::set_diagnostic_log_path_for_tests(Some(log_path.clone())); + diagnostic_log::append_diagnostic_log("string.event", "simple message").unwrap(); + diagnostic_log::set_diagnostic_log_path_for_tests(None); + + let contents = std::fs::read_to_string(&log_path).unwrap(); + let record: serde_json::Value = serde_json::from_str(contents.trim()).unwrap(); + assert_eq!(record["detail"], "simple message"); +} + +#[test] +fn diagnostic_log_path_uses_test_override() { + let _guard = LOG_PATH_LOCK.lock().unwrap(); + let custom = PathBuf::from("/tmp/custom-diagnostic-test.jsonl"); + + diagnostic_log::set_diagnostic_log_path_for_tests(Some(custom.clone())); + let path = diagnostic_log::diagnostic_log_path(); + diagnostic_log::set_diagnostic_log_path_for_tests(None); + + assert_eq!(path, custom); +} + +#[test] +fn diagnostic_log_path_falls_back_to_default_when_unset() { + let _guard = LOG_PATH_LOCK.lock().unwrap(); + diagnostic_log::set_diagnostic_log_path_for_tests(None); + let path = diagnostic_log::diagnostic_log_path(); + assert!(path.to_string_lossy().contains("codex")); +} + +#[test] +fn append_diagnostic_log_creates_parent_directories() { + let _guard = LOG_PATH_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let log_path = temp.path().join("a").join("b").join("c").join("diag.jsonl"); + + diagnostic_log::set_diagnostic_log_path_for_tests(Some(log_path.clone())); + diagnostic_log::append_diagnostic_log("nested.dir.test", json!(null)).unwrap(); + diagnostic_log::set_diagnostic_log_path_for_tests(None); + + assert!(log_path.exists()); +} diff --git a/crates/codex-plus-core/tests/script_market.rs b/crates/codex-plus-core/tests/script_market.rs new file mode 100644 index 000000000..7323670b7 --- /dev/null +++ b/crates/codex-plus-core/tests/script_market.rs @@ -0,0 +1,227 @@ +use serde_json::json; + +use codex_plus_core::script_market; + +#[test] +fn parse_market_manifest_extracts_version_and_scripts() { + let raw = json!({ + "version": 2, + "updated_at": "2026-01-15T10:00:00Z", + "scripts": [ + { + "id": "dark-mode", + "name": "Dark Mode", + "version": "1.0.0", + "script_url": "https://example.test/dark-mode.js", + "description": "Enables dark mode", + "author": "test-author", + "tags": ["ui", "theme"], + "homepage": "https://example.test", + "sha256": "abc123" + } + ] + }); + + let manifest = script_market::parse_market_manifest(raw).unwrap(); + + assert_eq!(manifest.version, 2); + assert_eq!(manifest.updated_at.as_deref(), Some("2026-01-15T10:00:00Z")); + assert_eq!(manifest.scripts.len(), 1); + + let script = &manifest.scripts[0]; + assert_eq!(script.id, "dark-mode"); + assert_eq!(script.name, "Dark Mode"); + assert_eq!(script.version, "1.0.0"); + assert_eq!(script.script_url, "https://example.test/dark-mode.js"); + assert_eq!(script.description, "Enables dark mode"); + assert_eq!(script.author, "test-author"); + assert_eq!(script.tags, vec!["ui", "theme"]); + assert_eq!(script.homepage, "https://example.test"); + assert_eq!(script.sha256, "abc123"); +} + +#[test] +fn parse_market_manifest_defaults_to_version_1_when_missing() { + let raw = json!({ + "scripts": [] + }); + + let manifest = script_market::parse_market_manifest(raw).unwrap(); + assert_eq!(manifest.version, 1); + assert!(manifest.updated_at.is_none()); + assert!(manifest.scripts.is_empty()); +} + +#[test] +fn parse_market_manifest_skips_scripts_with_missing_required_fields() { + let raw = json!({ + "version": 1, + "scripts": [ + { + "id": "valid-script", + "name": "Valid", + "version": "1.0", + "script_url": "https://example.test/valid.js" + }, + { + "name": "Missing ID", + "version": "1.0", + "script_url": "https://example.test/missing-id.js" + }, + { + "id": "missing-name", + "version": "1.0", + "script_url": "https://example.test/missing-name.js" + }, + { + "id": "missing-version", + "name": "No Version", + "script_url": "https://example.test/missing-version.js" + }, + { + "id": "missing-url", + "name": "No URL", + "version": "1.0" + } + ] + }); + + let manifest = script_market::parse_market_manifest(raw).unwrap(); + assert_eq!(manifest.scripts.len(), 1); + assert_eq!(manifest.scripts[0].id, "valid-script"); +} + +#[test] +fn parse_market_manifest_skips_empty_string_required_fields() { + let raw = json!({ + "scripts": [ + { + "id": "", + "name": "Empty ID", + "version": "1.0", + "script_url": "https://example.test/empty-id.js" + }, + { + "id": " ", + "name": "Whitespace ID", + "version": "1.0", + "script_url": "https://example.test/ws-id.js" + } + ] + }); + + let manifest = script_market::parse_market_manifest(raw).unwrap(); + assert!(manifest.scripts.is_empty()); +} + +#[test] +fn parse_market_manifest_handles_empty_scripts_array() { + let raw = json!({ "scripts": [] }); + let manifest = script_market::parse_market_manifest(raw).unwrap(); + assert!(manifest.scripts.is_empty()); +} + +#[test] +fn parse_market_manifest_handles_missing_scripts_key() { + let raw = json!({ "version": 3 }); + let manifest = script_market::parse_market_manifest(raw).unwrap(); + assert_eq!(manifest.version, 3); + assert!(manifest.scripts.is_empty()); +} + +#[test] +fn parse_market_manifest_trims_whitespace_from_updated_at() { + let raw = json!({ + "updated_at": " 2026-01-15 ", + "scripts": [] + }); + let manifest = script_market::parse_market_manifest(raw).unwrap(); + assert_eq!(manifest.updated_at.as_deref(), Some("2026-01-15")); +} + +#[test] +fn parse_market_manifest_ignores_empty_updated_at() { + let raw = json!({ + "updated_at": " ", + "scripts": [] + }); + let manifest = script_market::parse_market_manifest(raw).unwrap(); + assert!(manifest.updated_at.is_none()); +} + +#[test] +fn parse_market_manifest_defaults_optional_fields() { + let raw = json!({ + "scripts": [ + { + "id": "minimal", + "name": "Minimal Script", + "version": "0.1", + "script_url": "https://example.test/minimal.js" + } + ] + }); + + let manifest = script_market::parse_market_manifest(raw).unwrap(); + let script = &manifest.scripts[0]; + + assert_eq!(script.description, ""); + assert_eq!(script.author, ""); + assert!(script.tags.is_empty()); + assert_eq!(script.homepage, ""); + assert_eq!(script.sha256, ""); +} + +#[test] +fn parse_market_manifest_filters_empty_tags() { + let raw = json!({ + "scripts": [ + { + "id": "tag-test", + "name": "Tags", + "version": "1.0", + "script_url": "https://example.test/tags.js", + "tags": ["ui", "", " ", "theme"] + } + ] + }); + + let manifest = script_market::parse_market_manifest(raw).unwrap(); + assert_eq!(manifest.scripts[0].tags, vec!["ui", "theme"]); +} + +#[test] +fn install_market_script_content_writes_and_records() { + let temp = tempfile::tempdir().unwrap(); + let builtin_dir = temp.path().join("builtin"); + let user_dir = temp.path().join("user"); + let config_path = temp.path().join("config.json"); + + std::fs::create_dir_all(&builtin_dir).unwrap(); + std::fs::create_dir_all(&user_dir).unwrap(); + + let manager = codex_plus_core::user_scripts::UserScriptManager::new( + &builtin_dir, + &user_dir, + &config_path, + ); + + let script = script_market::MarketScript { + id: "test-script".to_string(), + name: "Test Script".to_string(), + description: "A test".to_string(), + version: "1.0.0".to_string(), + author: "tester".to_string(), + tags: vec![], + homepage: "https://example.test".to_string(), + script_url: "https://example.test/test.js".to_string(), + sha256: String::new(), + }; + + let content = b"console.log('hello');"; + script_market::install_market_script_content(&manager, &script, content).unwrap(); + + let expected_path = user_dir.join("market-test-script.js"); + assert!(expected_path.exists()); + assert_eq!(std::fs::read(&expected_path).unwrap(), content); +} diff --git a/crates/codex-plus-core/tests/user_scripts.rs b/crates/codex-plus-core/tests/user_scripts.rs new file mode 100644 index 000000000..5a831203c --- /dev/null +++ b/crates/codex-plus-core/tests/user_scripts.rs @@ -0,0 +1,292 @@ +use std::collections::BTreeMap; + +use codex_plus_core::user_scripts::{UserScriptConfig, UserScriptManager, market_script_filename}; + +#[test] +fn market_script_filename_sanitizes_special_characters() { + assert_eq!(market_script_filename("my-script"), "market-my-script.js"); + assert_eq!( + market_script_filename("my_script_v2"), + "market-my_script_v2.js" + ); + assert_eq!(market_script_filename("script@1.0"), "market-script-1-0.js"); + assert_eq!( + market_script_filename("hello world!"), + "market-hello-world.js" + ); +} + +#[test] +fn market_script_filename_trims_leading_trailing_dashes() { + assert_eq!( + market_script_filename("--my-script--"), + "market-my-script.js" + ); + assert_eq!(market_script_filename("@@@test@@@"), "market-test.js"); +} + +#[test] +fn market_script_filename_defaults_for_empty_id() { + assert_eq!(market_script_filename(""), "market-script.js"); + assert_eq!(market_script_filename("@@@"), "market-script.js"); +} + +#[test] +fn user_script_manager_default_config_has_scripts_enabled() { + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new( + temp.path().join("builtin"), + temp.path().join("user"), + temp.path().join("config.json"), + ); + + let config = manager.load_config(); + assert!(config.enabled); + assert!(config.scripts.is_empty()); + assert!(config.market.is_empty()); +} + +#[test] +fn user_script_manager_save_and_load_config_roundtrip() { + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new( + temp.path().join("builtin"), + temp.path().join("user"), + temp.path().join("config.json"), + ); + + let mut scripts = BTreeMap::new(); + scripts.insert("builtin:test.js".to_string(), true); + scripts.insert("user:custom.js".to_string(), false); + + let config = UserScriptConfig { + enabled: false, + scripts, + market: BTreeMap::new(), + }; + + manager.save_config(&config).unwrap(); + let loaded = manager.load_config(); + + assert_eq!(loaded.enabled, false); + assert_eq!(loaded.scripts.get("builtin:test.js"), Some(&true)); + assert_eq!(loaded.scripts.get("user:custom.js"), Some(&false)); +} + +#[test] +fn user_script_manager_set_global_enabled_toggles_flag() { + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new( + temp.path().join("builtin"), + temp.path().join("user"), + temp.path().join("config.json"), + ); + + let config = manager.set_global_enabled(false).unwrap(); + assert!(!config.enabled); + + let config = manager.set_global_enabled(true).unwrap(); + assert!(config.enabled); +} + +#[test] +fn user_script_manager_set_script_enabled_inserts_key() { + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new( + temp.path().join("builtin"), + temp.path().join("user"), + temp.path().join("config.json"), + ); + + let config = manager + .set_script_enabled("builtin:test.js", false) + .unwrap(); + assert_eq!(config.scripts.get("builtin:test.js"), Some(&false)); + + let config = manager.set_script_enabled("builtin:test.js", true).unwrap(); + assert_eq!(config.scripts.get("builtin:test.js"), Some(&true)); +} + +#[test] +fn user_script_manager_delete_user_script_removes_file_and_config() { + let temp = tempfile::tempdir().unwrap(); + let user_dir = temp.path().join("user"); + std::fs::create_dir_all(&user_dir).unwrap(); + + let script_path = user_dir.join("custom.js"); + std::fs::write(&script_path, "console.log('hello');").unwrap(); + + let manager = UserScriptManager::new( + temp.path().join("builtin"), + &user_dir, + temp.path().join("config.json"), + ); + + manager.set_script_enabled("user:custom.js", true).unwrap(); + let config = manager.delete_user_script("user:custom.js").unwrap(); + + assert!(!script_path.exists()); + assert!(!config.scripts.contains_key("user:custom.js")); +} + +#[test] +fn user_script_manager_delete_rejects_non_user_scripts() { + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new( + temp.path().join("builtin"), + temp.path().join("user"), + temp.path().join("config.json"), + ); + + assert!(manager.delete_user_script("builtin:test.js").is_err()); + assert!(manager.delete_user_script("").is_err()); + assert!(manager.delete_user_script("user:").is_err()); +} + +#[test] +fn user_script_manager_delete_rejects_path_traversal() { + let temp = tempfile::tempdir().unwrap(); + let user_dir = temp.path().join("user"); + std::fs::create_dir_all(&user_dir).unwrap(); + + let manager = UserScriptManager::new( + temp.path().join("builtin"), + &user_dir, + temp.path().join("config.json"), + ); + + assert!(manager.delete_user_script("user:../secret.js").is_err()); + assert!(manager.delete_user_script("user:sub/dir.js").is_err()); + assert!(manager.delete_user_script("user:..").is_err()); + assert!(manager.delete_user_script("user:.").is_err()); +} + +#[test] +fn user_script_manager_inventory_returns_json_with_expected_keys() { + let temp = tempfile::tempdir().unwrap(); + let builtin_dir = temp.path().join("builtin"); + let user_dir = temp.path().join("user"); + std::fs::create_dir_all(&builtin_dir).unwrap(); + std::fs::create_dir_all(&user_dir).unwrap(); + + std::fs::write(builtin_dir.join("enhance.js"), "// builtin").unwrap(); + std::fs::write(user_dir.join("custom.js"), "// user").unwrap(); + + let manager = UserScriptManager::new(&builtin_dir, &user_dir, temp.path().join("config.json")); + let inventory = manager.inventory().unwrap(); + + assert_eq!(inventory["enabled"], true); + let scripts = inventory["scripts"].as_array().unwrap(); + assert_eq!(scripts.len(), 2); + + let keys: Vec<&str> = scripts.iter().map(|s| s["key"].as_str().unwrap()).collect(); + assert!(keys.contains(&"builtin:enhance.js")); + assert!(keys.contains(&"user:custom.js")); +} + +#[test] +fn user_script_manager_build_enabled_bundle_concatenates_enabled_scripts() { + let temp = tempfile::tempdir().unwrap(); + let builtin_dir = temp.path().join("builtin"); + let user_dir = temp.path().join("user"); + std::fs::create_dir_all(&builtin_dir).unwrap(); + std::fs::create_dir_all(&user_dir).unwrap(); + + std::fs::write(builtin_dir.join("a.js"), "console.log('a');").unwrap(); + std::fs::write(user_dir.join("b.js"), "console.log('b');").unwrap(); + + let manager = UserScriptManager::new(&builtin_dir, &user_dir, temp.path().join("config.json")); + let bundle = manager.build_enabled_bundle().unwrap(); + + assert!(bundle.contains("console.log('a');")); + assert!(bundle.contains("console.log('b');")); + assert!(bundle.contains("__codexPlusUserScripts")); +} + +#[test] +fn user_script_manager_build_enabled_bundle_empty_when_globally_disabled() { + let temp = tempfile::tempdir().unwrap(); + let builtin_dir = temp.path().join("builtin"); + let user_dir = temp.path().join("user"); + std::fs::create_dir_all(&builtin_dir).unwrap(); + std::fs::create_dir_all(&user_dir).unwrap(); + + std::fs::write(builtin_dir.join("a.js"), "console.log('a');").unwrap(); + + let manager = UserScriptManager::new(&builtin_dir, &user_dir, temp.path().join("config.json")); + manager.set_global_enabled(false).unwrap(); + + let bundle = manager.build_enabled_bundle().unwrap(); + assert!(bundle.is_empty()); +} + +#[test] +fn user_script_manager_build_enabled_bundle_skips_disabled_scripts() { + let temp = tempfile::tempdir().unwrap(); + let builtin_dir = temp.path().join("builtin"); + let user_dir = temp.path().join("user"); + std::fs::create_dir_all(&builtin_dir).unwrap(); + std::fs::create_dir_all(&user_dir).unwrap(); + + std::fs::write(builtin_dir.join("enabled.js"), "// enabled").unwrap(); + std::fs::write(builtin_dir.join("disabled.js"), "// disabled").unwrap(); + + let manager = UserScriptManager::new(&builtin_dir, &user_dir, temp.path().join("config.json")); + manager + .set_script_enabled("builtin:disabled.js", false) + .unwrap(); + + let bundle = manager.build_enabled_bundle().unwrap(); + assert!(bundle.contains("// enabled")); + assert!(!bundle.contains("// disabled")); +} + +#[test] +fn user_script_manager_load_config_ignores_invalid_json() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.json"); + std::fs::write(&config_path, "not valid json").unwrap(); + + let manager = UserScriptManager::new( + temp.path().join("builtin"), + temp.path().join("user"), + &config_path, + ); + + let config = manager.load_config(); + assert!(config.enabled); + assert!(config.scripts.is_empty()); +} + +#[test] +fn user_script_path_for_market_id_maps_to_user_dir() { + let temp = tempfile::tempdir().unwrap(); + let user_dir = temp.path().join("user"); + let manager = UserScriptManager::new( + temp.path().join("builtin"), + &user_dir, + temp.path().join("config.json"), + ); + + let path = manager.user_script_path_for_market_id("dark-mode"); + assert_eq!(path, user_dir.join("market-dark-mode.js")); +} + +#[test] +fn user_script_manager_only_collects_js_files() { + let temp = tempfile::tempdir().unwrap(); + let builtin_dir = temp.path().join("builtin"); + let user_dir = temp.path().join("user"); + std::fs::create_dir_all(&builtin_dir).unwrap(); + std::fs::create_dir_all(&user_dir).unwrap(); + + std::fs::write(builtin_dir.join("script.js"), "// js").unwrap(); + std::fs::write(builtin_dir.join("readme.txt"), "not a script").unwrap(); + std::fs::write(builtin_dir.join("data.json"), "{}").unwrap(); + + let manager = UserScriptManager::new(&builtin_dir, &user_dir, temp.path().join("config.json")); + let inventory = manager.inventory().unwrap(); + let scripts = inventory["scripts"].as_array().unwrap(); + assert_eq!(scripts.len(), 1); + assert_eq!(scripts[0]["key"].as_str().unwrap(), "builtin:script.js"); +} diff --git a/crates/codex-plus-data/tests/backup.rs b/crates/codex-plus-data/tests/backup.rs new file mode 100644 index 000000000..29c3b2673 --- /dev/null +++ b/crates/codex-plus-data/tests/backup.rs @@ -0,0 +1,109 @@ +use std::path::Path; + +use codex_plus_data::backup::BackupStore; +use serde_json::json; + +#[test] +fn write_and_read_backup_roundtrip() { + let temp = tempfile::tempdir().unwrap(); + let store = BackupStore::new(temp.path().join("backups")); + + let tables = json!({ + "threads": [{"id": "t1", "title": "Test Thread"}], + "messages": [] + }); + + let token = store + .write_backup("session-123", Path::new("/db/state.sqlite"), tables.clone()) + .unwrap(); + + assert!(!token.is_empty()); + + let backup = store.read_backup(&token).unwrap(); + assert_eq!(backup["session_id"], "session-123"); + assert_eq!(backup["source_db"], "/db/state.sqlite"); + assert_eq!(backup["tables"], tables); + assert_eq!(backup["token"], token); +} + +#[test] +fn write_backup_creates_root_directory() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("nested").join("backups"); + let store = BackupStore::new(&root); + + let token = store + .write_backup("s1", Path::new("/db.sqlite"), json!({})) + .unwrap(); + + assert!(root.is_dir()); + assert!(store.path_for(&token).exists()); +} + +#[test] +fn read_backup_returns_error_for_unknown_token() { + let temp = tempfile::tempdir().unwrap(); + let store = BackupStore::new(temp.path()); + + let result = store.read_backup("nonexistent-token"); + assert!(result.is_err()); +} + +#[test] +fn path_for_sanitizes_token_characters() { + let temp = tempfile::tempdir().unwrap(); + let store = BackupStore::new(temp.path()); + + let safe_path = store.path_for("abc-123_def"); + assert!(safe_path.to_string_lossy().ends_with("abc-123_def.json")); + + let dangerous_path = store.path_for("../../../etc/passwd"); + let filename = dangerous_path.file_name().unwrap().to_string_lossy(); + assert!(!filename.contains("..")); + assert!(!filename.contains('/')); + assert!(filename.ends_with(".json")); +} + +#[test] +fn path_for_strips_special_characters() { + let temp = tempfile::tempdir().unwrap(); + let store = BackupStore::new(temp.path()); + + let path = store.path_for("token!@#$%^&*()"); + let filename = path.file_name().unwrap().to_string_lossy(); + assert_eq!(filename.as_ref(), "token.json"); +} + +#[test] +fn multiple_backups_produce_unique_tokens() { + let temp = tempfile::tempdir().unwrap(); + let store = BackupStore::new(temp.path().join("backups")); + + let token1 = store + .write_backup("s1", Path::new("/db.sqlite"), json!({"a": 1})) + .unwrap(); + let token2 = store + .write_backup("s2", Path::new("/db.sqlite"), json!({"b": 2})) + .unwrap(); + + assert_ne!(token1, token2); + + let b1 = store.read_backup(&token1).unwrap(); + let b2 = store.read_backup(&token2).unwrap(); + assert_eq!(b1["session_id"], "s1"); + assert_eq!(b2["session_id"], "s2"); +} + +#[test] +fn write_backup_stores_pretty_json() { + let temp = tempfile::tempdir().unwrap(); + let store = BackupStore::new(temp.path().join("backups")); + + let token = store + .write_backup("s1", Path::new("/db.sqlite"), json!({"key": "value"})) + .unwrap(); + + let raw = std::fs::read_to_string(store.path_for(&token)).unwrap(); + assert!(raw.contains('\n')); + assert!(raw.contains(" ")); +}