diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e401dbb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Neovim + uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: stable + + - name: Run tests + run: nvim -l tests/run.lua + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check formatting + uses: JohnnyMorganz/stylua-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: latest + args: --check lua/ tests/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3f09981 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: test lint format + +# Run the unit test suite. Pass PATTERN= to run a subset: +# make test PATTERN=mount_point +test: + nvim -l tests/run.lua $(PATTERN) + +lint: + stylua --check lua/ tests/ + +format: + stylua lua/ tests/ diff --git a/README.md b/README.md index ee346f6..ec5dd4f 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,7 @@ require("sshfs").setup({ - `:checkhealth sshfs` - Verify dependencies and configuration - `:SSHConnect [host]` - Mount a remote host +- `:SSHTest [host]` - Test SSH resolution and authentication without mounting - `:SSHDisconnect` - Unmount current host - `:SSHDisconnectAll` - Unmount all hosts - `:SSHConfig` - Edit SSH config files @@ -342,3 +343,13 @@ Auth flow: keys first, then floating terminal for passphrases/passwords/2FA; Con - **Configure `global_paths`** with common directories (`/var/www`, `/var/log`, `~/.config`) to have them available across all hosts - **Configure `host_paths`** for frequently-used hosts to skip path selection - **Set `preferred_picker` for local/remote pickers** to force specific file picker(s) instead of auto-detection + +## 🧪 Development + +Run the unit test suite with: + +```sh +make test +``` + +Tests run in headless Neovim with no external dependencies and never contact a real SSH server or mount table. See [`tests/README.md`](tests/README.md) for the harness, the available stubs, and how to add coverage. CI runs the suite and `stylua --check` on every pull request. diff --git a/lua/sshfs/api.lua b/lua/sshfs/api.lua index 1e5d562..55d4ad8 100644 --- a/lua/sshfs/api.lua +++ b/lua/sshfs/api.lua @@ -18,6 +18,20 @@ Api.connect = function(host) end end +--- Test an SSH host without mounting - use picker if no host provided. +--- @param host table|nil SSH host object (optional) +Api.test = function(host) + local Diagnostic = require("sshfs.diagnostic") + if host then + Diagnostic.test(host) + else + local Select = require("sshfs.ui.select") + Select.host(function(selected_host) + if selected_host then Diagnostic.test(selected_host) end + end) + end +end + --- Mount SSH host (alias for connect) Api.mount = function() Api.connect() @@ -325,6 +339,16 @@ Api.setup = function() end end, { nargs = "?", desc = "Remotely connect to host via picker or command as argument." }) + vim.api.nvim_create_user_command("SSHTest", function(opts) + if opts.args and opts.args ~= "" then + local SSHConfig = require("sshfs.lib.ssh_config") + local host = SSHConfig.parse_host(opts.args) + Api.test(host) + else + Api.test() + end + end, { nargs = "?", desc = "Test SSH connection and show resolved parameters and exit codes" }) + vim.api.nvim_create_user_command("SSHConfig", function() Api.config() end, { desc = "Edit SSH config files" }) diff --git a/lua/sshfs/diagnostic.lua b/lua/sshfs/diagnostic.lua new file mode 100644 index 0000000..4d3b104 --- /dev/null +++ b/lua/sshfs/diagnostic.lua @@ -0,0 +1,167 @@ +-- lua/sshfs/diagnostic.lua +-- Read-only SSH connection preflight diagnostics. + +local Diagnostic = {} + +local function shell_join(cmd) + return table.concat(vim.tbl_map(vim.fn.shellescape, cmd), " ") +end + +local function run(cmd, callback) + vim.system(cmd, { text = true }, function(obj) + vim.schedule(function() + callback({ + command = cmd, + code = obj.code, + stdout = vim.trim(obj.stdout or ""), + stderr = vim.trim(obj.stderr or ""), + }) + end) + end) +end + +local function parse_ssh_config(output) + local resolved = {} + for line in output:gmatch("[^\r\n]+") do + local key, value = line:match("^(%S+)%s+(.+)$") + if key and value then + if resolved[key] == nil then + resolved[key] = value + elseif key == "identityfile" then + resolved[key] = resolved[key] .. ", " .. value + end + end + end + return resolved +end + +local function append_output(lines, label, output) + if output == "" then return end + table.insert(lines, label .. ":") + for line in output:gmatch("[^\r\n]+") do + table.insert(lines, " " .. line) + end +end + +--- @param include_stdout boolean|nil Set to false to omit stdout (already summarized elsewhere) +local function append_result(lines, name, result, include_stdout) + table.insert(lines, string.format("[%s] %s", result.code == 0 and "PASS" or "FAIL", name)) + table.insert(lines, "Command: " .. shell_join(result.command)) + table.insert(lines, "Exit code: " .. tostring(result.code)) + if include_stdout ~= false then append_output(lines, "stdout", result.stdout) end + append_output(lines, "stderr", result.stderr) + table.insert(lines, "") +end + +local function show_report(host, resolved, config_result, auth_result, home_result) + local Sshfs = require("sshfs.lib.sshfs") + local lines = { + "sshfs.nvim connection test", + "============================", + "", + "Input", + " host: " .. tostring(host.name), + " user: " .. tostring(host.user or "(SSH config/default)"), + " path: " .. tostring(host.path or "(selected during connect)"), + " port: " .. tostring(host.port or "(SSH config/default)"), + "", + "Resolved SSH configuration", + " hostname: " .. tostring(resolved.hostname or "unknown"), + " user: " .. tostring(resolved.user or "unknown"), + " port: " .. tostring(resolved.port or "unknown"), + " proxyjump: " .. tostring(resolved.proxyjump or "none"), + " identityfile: " .. tostring(resolved.identityfile or "default"), + "", + "Tests", + "", + } + + -- `ssh -G` prints the whole resolved config; the summary above already covers + -- the fields that matter, so the raw dump is omitted to keep the report readable. + append_result(lines, "SSH configuration", config_result, false) + append_result(lines, "SSH authentication", auth_result) + if home_result then append_result(lines, "Remote home", home_result) end + + table.insert(lines, "SSHFS command") + if host.path and host.path ~= "" then + local remote_path = host.path + if home_result and home_result.code == 0 and remote_path:match("^~") then + local home_path = vim.trim(home_result.stdout) + if home_path:sub(1, 1) == "/" then remote_path = remote_path:gsub("^~", home_path) end + end + local mount_cmd = Sshfs.build_mount_command(host, "", remote_path) + table.insert(lines, " " .. shell_join(mount_cmd)) + table.insert(lines, " Note: is determined by SSHConnect and is not created by SSHTest.") + else + table.insert(lines, " Not shown: SSHConnect prompts for a remote path before the mount command can be determined.") + end + table.insert(lines, "") + table.insert(lines, "This preflight does not mount SSHFS or leave diagnostic ControlMaster state behind.") + + vim.cmd("new") + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_name(buf, string.format("sshfs://test/%s/%d", host.name, buf)) + vim.bo[buf].buftype = "nofile" + vim.bo[buf].bufhidden = "wipe" + vim.bo[buf].swapfile = false + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = false +end + +--- Run a read-only preflight for a host and show a scratch-buffer report. +---@param host table Host object produced by ssh_config.parse_host/get_host_config +function Diagnostic.test(host) + local Ssh = require("sshfs.lib.ssh") + + local config_cmd = { "ssh", "-G" } + if host.port then vim.list_extend(config_cmd, { "-p", tostring(host.port) }) end + if host.user then vim.list_extend(config_cmd, { "-l", host.user }) end + table.insert(config_cmd, host.name) + + run(config_cmd, function(config_result) + local resolved = parse_ssh_config(config_result.stdout) + local pid_before = Ssh.control_master_pid(host) + local socket_dir, socket_error = Ssh.prepare_socket_dir() + + if not socket_dir then + show_report(host, resolved, config_result, { + command = Ssh.build_batch_command(host), + code = 1, + stdout = "", + stderr = socket_error or "Failed to prepare SSH control socket directory", + }, nil) + return + end + + -- Only a master this preflight started may be closed. Closing on "no master + -- existed beforehand" alone would race with a concurrent :SSHConnect that + -- creates the socket in the meantime, tearing down that connection instead. + local owned_pid = nil + + local function finish(auth_result, home_result) + if owned_pid and Ssh.control_master_pid(host) == owned_pid then Ssh.cleanup_control_master(host) end + show_report(host, resolved, config_result, auth_result, home_result) + end + + local auth_cmd = Ssh.build_batch_command(host) + run(auth_cmd, function(auth_result) + -- ControlMaster=yes refuses to take over an existing socket, so a master + -- is this command's own only when none existed beforehand and the command + -- itself connected successfully. + if pid_before == nil and auth_result.code == 0 then owned_pid = Ssh.control_master_pid(host) end + + local needs_home = host.path and host.path:match("^~") + if not needs_home or auth_result.code ~= 0 then + finish(auth_result, nil) + return + end + + run(Ssh.build_home_command(host), function(home_result) + finish(auth_result, home_result) + end) + end) + end) +end + +return Diagnostic diff --git a/lua/sshfs/init.lua b/lua/sshfs/init.lua index f31434f..7522205 100644 --- a/lua/sshfs/init.lua +++ b/lua/sshfs/init.lua @@ -84,5 +84,6 @@ App.explore = Api.explore App.change_dir = Api.change_dir App.ssh_terminal = Api.ssh_terminal App.command = Api.command +App.test = Api.test return App diff --git a/lua/sshfs/lib/ssh.lua b/lua/sshfs/lib/ssh.lua index f31a8b7..a8d0017 100644 --- a/lua/sshfs/lib/ssh.lua +++ b/lua/sshfs/lib/ssh.lua @@ -60,6 +60,34 @@ local function get_ssh_options(auth_type) return options end +local function normalize_host(host) + if type(host) == "table" then return host end + return { name = host } +end + +local function append_host_options(cmd, host) + host = normalize_host(host) + if host.port then vim.list_extend(cmd, { "-p", tostring(host.port) }) end + if host.user then vim.list_extend(cmd, { "-l", host.user }) end + table.insert(cmd, host.name) +end + +local function build_with_options(host, auth_type) + local cmd = { "ssh" } + for _, opt in ipairs(get_ssh_options(auth_type)) do + vim.list_extend(cmd, { "-o", opt }) + end + append_host_options(cmd, host) + return cmd +end + +--- Ensure the configured SSH ControlMaster socket directory exists. +--- @return string|nil socket_dir +--- @return string|nil error_msg +function Ssh.prepare_socket_dir() + return get_or_create_socket_dir() +end + --- Build SSH command string with options for use with sshfs ssh_command option --- @param auth_type string|nil Authentication type ("batch", "socket", or nil) --- @return string SSH command string (e.g., "ssh -o ControlMaster=auto -o ControlPath=... -o BatchMode=yes") @@ -75,6 +103,63 @@ function Ssh.build_command_string(auth_type) return table.concat(cmd_parts, " ") end +--- Build the same non-interactive authentication command used by SSHConnect. +---@param host table|string Host object or SSH host name +---@return table SSH command array +function Ssh.build_batch_command(host) + local cmd = build_with_options(host, "batch") + table.insert(cmd, "exit") + return cmd +end + +--- Build the command used to resolve the remote home through an established socket. +---@param host table|string Host object or SSH host name +---@return table SSH command array +function Ssh.build_home_command(host) + local cmd = build_with_options(host, "socket") + table.insert(cmd, "readlink -f $HOME 2>/dev/null || echo $HOME") + return cmd +end + +--- Build the interactive authentication command used after batch auth fails. +---@param host table|string Host object or SSH host name +---@return table SSH command array +function Ssh.build_auth_command(host) + local cmd = { "ssh" } + for _, opt in ipairs(get_ssh_options(nil)) do + if opt:match("^ControlMaster=") then opt = "ControlMaster=yes" end + vim.list_extend(cmd, { "-o", opt }) + end + append_host_options(cmd, host) + table.insert(cmd, "exit") + return cmd +end + +--- Build a ControlMaster management command. +---@param host table|string SSH host object or name +---@param operation string Control operation such as "check" or "exit" +---@return table SSH command array +function Ssh.build_control_command(host, operation) + local cmd = { "ssh" } + for _, opt in ipairs(get_ssh_options("socket")) do + vim.list_extend(cmd, { "-o", opt }) + end + vim.list_extend(cmd, { "-O", operation }) + append_host_options(cmd, host) + return cmd +end + +--- Return the pid of the active ControlMaster for a host, if one is running. +--- The pid identifies a specific master process, so a caller that created a +--- master can prove the socket still belongs to it before closing it. +---@param host table|string SSH host object or name +---@return number|nil pid Master pid, or nil when no master is running +function Ssh.control_master_pid(host) + local output = vim.fn.system(Ssh.build_control_command(host, "check")) + if vim.v.shell_error ~= 0 then return nil end + return tonumber((output or ""):match("pid=(%d+)")) +end + --- Build a safe cd command that handles tilde expansion and path escaping --- @param remote_path string Remote path to cd into --- @return string Shell command to cd into the path @@ -96,20 +181,11 @@ local function build_cd_command(remote_path) end --- Build SSH command with optional remote path and ControlMaster options ----@param host string SSH host name +---@param host table|string SSH host object or name ---@param remote_path string|nil Optional remote path to cd into ---@return table SSH command as array (safer than string to avoid shell injection) function Ssh.build_command(host, remote_path) - local cmd = { "ssh" } - - -- Add SSH options (ControlMaster, etc.) - local options = get_ssh_options(nil) -- No auth type for interactive terminal - for _, opt in ipairs(options) do - table.insert(cmd, "-o") - table.insert(cmd, opt) - end - - table.insert(cmd, host) + local cmd = build_with_options(host, nil) -- If remote_path specified, cd into it and start a login shell if remote_path and remote_path ~= "" then @@ -122,7 +198,7 @@ function Ssh.build_command(host, remote_path) end --- Open SSH terminal session ----@param host string SSH host name +---@param host table|string SSH host object or name ---@param remote_path string|nil Optional remote path to cd into function Ssh.open_terminal(host, remote_path) local ssh_cmd = Ssh.build_command(host, remote_path) @@ -134,21 +210,10 @@ end --- Get remote home directory by executing 'echo $HOME' on the remote server (async) --- This handles non-standard home directory structures (e.g., /home//) --- Uses existing ControlMaster socket if available for zero authentication overhead ----@param host string SSH host name +---@param host table|string SSH host object or name ---@param callback function Callback(home_path: string|nil, error: string|nil) function Ssh.get_remote_home(host, callback) - local cmd = { "ssh" } - - -- Add ControlPath option to reuse existing socket - local options = get_ssh_options("socket") - for _, opt in ipairs(options) do - table.insert(cmd, "-o") - table.insert(cmd, opt) - end - - table.insert(cmd, host) - -- Use readlink -f to resolve symlinks and get the canonical path with fallback if no readlink - table.insert(cmd, "readlink -f $HOME 2>/dev/null || echo $HOME") + local cmd = Ssh.build_home_command(host) -- Execute asynchronously vim.system(cmd, { text = true }, function(obj) @@ -170,31 +235,18 @@ end --- Close ControlMaster connection and clean up socket --- Sends "exit" command to ControlMaster to gracefully close connection and remove socket ----@param host string SSH host name +---@param host table|string SSH host object or name ---@return boolean True if cleanup command was sent successfully function Ssh.cleanup_control_master(host) - local Config = require("sshfs.config") - local control_opts = Config.get_control_master_options() - - -- Build ssh -O exit command for ControlPath - local cmd = { "ssh" } - for _, opt in ipairs(control_opts) do - table.insert(cmd, "-o") - table.insert(cmd, opt) - end - table.insert(cmd, "-O") - table.insert(cmd, "exit") - table.insert(cmd, host) - -- Execute synchronously (must complete before nvim exit) - vim.fn.system(cmd) + vim.fn.system(Ssh.build_control_command(host, "exit")) -- Ignore exit code - socket may already be closed/expired return true end --- Try batch SSH connection to establish ControlMaster socket (async, non-interactive) --- Attempts to connect using existing keys without prompting for passwords or passphrases ----@param host string SSH host name +---@param host table|string SSH host object or name ---@param callback function Callback(success: boolean, exit_code: number, error: string|nil) function Ssh.try_batch_connect(host, callback) -- Ensure socket directory exists before attempting connection @@ -206,20 +258,8 @@ function Ssh.try_batch_connect(host, callback) return end - local cmd = { "ssh" } - - -- Add SSH options for batch connection (ControlMaster=yes + BatchMode=yes) - local options = get_ssh_options("batch") - for _, opt in ipairs(options) do - table.insert(cmd, "-o") - table.insert(cmd, opt) - end - - -- Add host and exit command (just test connection, don't start shell) - table.insert(cmd, host) - table.insert(cmd, "exit") - - -- Execute asynchronously + -- Build and execute the batch command asynchronously + local cmd = Ssh.build_batch_command(host) vim.system(cmd, { text = true }, function(obj) vim.schedule(function() local success = obj.code == 0 @@ -232,7 +272,7 @@ end --- Open interactive SSH terminal for authentication in floating window (async) --- Allows user to complete any SSH authentication method (password, 2FA, host verification, etc.) --- Creates floating terminal window and tracks exit code for success/failure ----@param host string SSH host name +---@param host table|string SSH host object or name ---@param callback function Callback(success: boolean, exit_code: number) function Ssh.open_auth_terminal(host, callback) -- Ensure socket directory exists before attempting connection @@ -246,28 +286,12 @@ function Ssh.open_auth_terminal(host, callback) end -- Build SSH command for authentication (ControlMaster=yes to create socket) - local cmd = { "ssh" } - local options = get_ssh_options(nil) -- Get ControlMaster options - local modified_opts = {} - for _, opt in ipairs(options) do - if opt:match("^ControlMaster=") then - table.insert(modified_opts, "ControlMaster=yes") - else - table.insert(modified_opts, opt) - end - end - - -- Finalize command options, end with exit to close shell after authentication flow - for _, opt in ipairs(modified_opts) do - table.insert(cmd, "-o") - table.insert(cmd, opt) - end - table.insert(cmd, host) - table.insert(cmd, "exit") + local host_obj = normalize_host(host) + local cmd = Ssh.build_auth_command(host_obj) -- Open authentication terminal window local Terminal = require("sshfs.ui.terminal") - Terminal.open_auth_floating(cmd, host, callback) + Terminal.open_auth_floating(cmd, host_obj.name, callback) end return Ssh diff --git a/lua/sshfs/lib/sshfs.lua b/lua/sshfs/lib/sshfs.lua index 9e5131d..f0b3617 100644 --- a/lua/sshfs/lib/sshfs.lua +++ b/lua/sshfs/lib/sshfs.lua @@ -20,6 +20,7 @@ local function build_sshfs_args(options_table) -- false or nil: skip this option end + table.sort(result) return result end @@ -34,8 +35,7 @@ local function get_sshfs_options(auth_type) -- Add user-configured sshfs options from config (convert table to array) if opts.connections and opts.connections.sshfs_options then - local sshfs_opts = build_sshfs_args(opts.connections.sshfs_options) - vim.list_extend(options, sshfs_opts) + vim.list_extend(options, build_sshfs_args(opts.connections.sshfs_options)) end -- Add SSH command to reuse existing ControlMaster socket @@ -47,12 +47,12 @@ local function get_sshfs_options(auth_type) return options end ---- Execute the actual mount command (private helper) +--- Build the SSHFS command used to mount an already resolved remote path. --- @param host table Host object with name, user, port, and path fields --- @param mount_point string Local mount point directory --- @param remote_path_suffix string Remote path to mount (already resolved) ---- @param callback function Callback function(result: table) - result has fields: success, message, resolved_path -local function mount_with_path(host, mount_point, remote_path_suffix, callback) +--- @return table command SSHFS command array +function Sshfs.build_mount_command(host, mount_point, remote_path_suffix) local options = get_sshfs_options("socket") -- Use host.name (the alias) to let SSH config resolution work properly @@ -62,10 +62,17 @@ local function mount_with_path(host, mount_point, remote_path_suffix, callback) -- Add options/port local cmd = { "sshfs", remote_path, mount_point, "-o", table.concat(options, ",") } - if host.port then - table.insert(cmd, "-p") - table.insert(cmd, host.port) - end + if host.port then vim.list_extend(cmd, { "-p", tostring(host.port) }) end + return cmd +end + +--- Execute the actual mount command (private helper) +--- @param host table Host object with name, user, port, and path fields +--- @param mount_point string Local mount point directory +--- @param remote_path_suffix string Remote path to mount (already resolved) +--- @param callback function Callback function(result: table) - result has fields: success, message, resolved_path +local function mount_with_path(host, mount_point, remote_path_suffix, callback) + local cmd = Sshfs.build_mount_command(host, mount_point, remote_path_suffix) -- Execute mount command asynchronously vim.system(cmd, { text = true }, function(obj) @@ -100,7 +107,7 @@ local function mount_via_socket(host, mount_point, remote_path_suffix, callback) -- This handles symlinked home directories and non-standard structures if remote_path_suffix:match("^~") then local Ssh = require("sshfs.lib.ssh") - Ssh.get_remote_home(host.name, function(actual_home, error) + Ssh.get_remote_home(host, function(actual_home, error) if actual_home then -- Replace ~ with the actual home path and mount local resolved_path = remote_path_suffix:gsub("^~", actual_home) @@ -130,14 +137,14 @@ function Sshfs.authenticate_and_mount(host, mount_point, remote_path_suffix, cal vim.notify("Connecting to " .. host.name .. "...", vim.log.levels.INFO) -- Try batch connection (non-interactive) - Ssh.try_batch_connect(host.name, function(success, exit_code, error) + Ssh.try_batch_connect(host, function(success, exit_code, error) if success then mount_via_socket(host, mount_point, remote_path_suffix, callback) return end -- Batch failed, try interactive terminal - Ssh.open_auth_terminal(host.name, function(term_success, term_exit_code) + Ssh.open_auth_terminal(host, function(term_success, term_exit_code) if term_success then mount_via_socket(host, mount_point, remote_path_suffix, callback) else diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..3c7b43a --- /dev/null +++ b/tests/README.md @@ -0,0 +1,55 @@ +# Tests + +Unit tests for sshfs.nvim. They run inside headless Neovim, use no external dependencies, and never contact a real SSH server or mount table. + +## Running + +```sh +make test # everything +make test PATTERN=mount_point # only spec files whose path contains the pattern +nvim -l tests/run.lua # same as `make test` +``` + +The runner exits non-zero when any case fails, so it can gate CI. `.github/workflows/test.yml` runs the suite and `stylua --check` on every pull request. + +## Layout + +| File | Purpose | +| --- | --- | +| `harness.lua` | `describe`/`it` registry, assertions, result reporting | +| `stub.lua` | Replaces the system-facing calls and restores them afterwards | +| `run.lua` | Entry point: discovers `tests/*_spec.lua` and exits with the result | +| `*_spec.lua` | One spec file per module under test | + +## Writing a test + +Spec files are plain Lua. The runner exposes `describe`, `it`, `expect`, and `stub` as globals, so no requires are needed. + +```lua +describe("MountPoint.list_active", function() + it("ignores mounts outside the configured base directory", function() + stub.reload() + require("sshfs.config").setup({ mounts = { base_dir = "/home/tester/mnt" } }) + stub.executable({}) + stub.system(function() + return "deploy@example.com:/srv/app on /somewhere/else type fuse.sshfs (rw)", 0 + end) + + expect.eq(require("sshfs.lib.mount_point").list_active(), {}) + stub.restore_all() + end) +end) +``` + +### Assertions + +`expect.eq` (deep equality), `expect.truthy`, `expect.falsy`, `expect.is_nil`, `expect.contains` (plain substring), `expect.errors`, `expect.no_error` (which forwards every value the call returned). Each takes an optional trailing context string that is shown on failure. + +### Stubs + +`stub.system(handler)` replaces `vim.fn.system` and the `v:shell_error` it reports; the handler returns `output, code`. `stub.vim_system(handler)` replaces `vim.system`, supporting both the async-callback and `:wait()` call styles, and returns the list of commands it received. `stub.executable(names)` controls `vim.fn.executable`. `stub.notifications()` captures `vim.notify` calls instead of printing them. `stub.set(path, value)` replaces any other field under `vim`. + +Two rules matter: + +- Call `stub.restore_all()` at the end of a case that stubbed anything. +- Call `stub.reload()` before requiring a plugin module when the test depends on fresh module state. Several modules memoize (configuration, SSHFS version detection), so a stale load will leak state between cases. diff --git a/tests/config_spec.lua b/tests/config_spec.lua new file mode 100644 index 0000000..c8aaf00 --- /dev/null +++ b/tests/config_spec.lua @@ -0,0 +1,98 @@ +-- tests/config_spec.lua +-- Configuration merging, accessors, and deprecation shims + +local function load_config(user_config) + stub.reload() + local Config = require("sshfs.config") + Config.setup(user_config) + return Config +end + +describe("Config.setup", function() + it("keeps defaults the user did not override", function() + local Config = load_config({ mounts = { base_dir = "/custom/mnt" } }) + local opts = Config.get() + + expect.eq(opts.mounts.base_dir, "/custom/mnt") + expect.eq(opts.connections.sshfs_options.reconnect, true) + expect.eq(opts.connections.sshfs_options.ConnectTimeout, 5) + end) + + it("merges nested tables instead of replacing them", function() + local Config = load_config({ connections = { sshfs_options = { ConnectTimeout = 30 } } }) + local options = Config.get().connections.sshfs_options + + expect.eq(options.ConnectTimeout, 30, "the overridden value wins") + expect.eq(options.compression, "yes", "sibling defaults survive the merge") + end) + + it("accepts no user configuration at all", function() + local Config = load_config(nil) + expect.truthy(Config.get().mounts.base_dir) + end) +end) + +describe("Config accessors", function() + it("returns the configured mount base directory", function() + local Config = load_config({ mounts = { base_dir = "/custom/mnt" } }) + expect.eq(Config.get_base_dir(), "/custom/mnt") + end) + + it("returns the configured socket directory", function() + local Config = load_config({ connections = { socket_dir = "/custom/sockets" } }) + expect.eq(Config.get_socket_dir(), "/custom/sockets") + end) + + it("builds ControlMaster options from the socket directory and persist window", function() + local Config = load_config({ connections = { socket_dir = "/custom/sockets", control_persist = "5m" } }) + + expect.eq(Config.get_control_master_options(), { + "ControlMaster=auto", + "ControlPath=/custom/sockets/%C", + "ControlPersist=5m", + }) + end) +end) + +describe("Config deprecations", function() + it("maps ui.file_picker onto ui.local_picker and warns", function() + stub.reload() + local notifications = stub.notifications() + local Config = require("sshfs.config") + Config.setup({ ui = { file_picker = { preferred_picker = "telescope" } } }) + stub.restore_all() + + expect.eq(Config.get().ui.local_picker.preferred_picker, "telescope") + expect.eq(#notifications, 1) + expect.contains(notifications[1].message, "ui.file_picker") + end) + + it("maps mounts.unmount_on_exit onto hooks.on_exit.auto_unmount", function() + stub.reload() + stub.notifications() + local Config = require("sshfs.config") + Config.setup({ mounts = { unmount_on_exit = true } }) + stub.restore_all() + + expect.eq(Config.get().hooks.on_exit.auto_unmount, true) + end) + + it("maps mounts.auto_change_dir_on_mount onto hooks.on_mount.auto_change_to_dir", function() + stub.reload() + stub.notifications() + local Config = require("sshfs.config") + Config.setup({ mounts = { auto_change_dir_on_mount = false } }) + stub.restore_all() + + expect.eq(Config.get().hooks.on_mount.auto_change_to_dir, false) + end) + + it("stays quiet when no deprecated key is used", function() + stub.reload() + local notifications = stub.notifications() + require("sshfs.config").setup({ mounts = { base_dir = "/custom/mnt" } }) + stub.restore_all() + + expect.eq(#notifications, 0) + end) +end) diff --git a/tests/diagnostic_spec.lua b/tests/diagnostic_spec.lua new file mode 100644 index 0000000..7da1177 --- /dev/null +++ b/tests/diagnostic_spec.lua @@ -0,0 +1,237 @@ +-- tests/diagnostic_spec.lua +-- :SSHTest preflight reporting and ControlMaster handling + +local SSH_G_OUTPUT = table.concat({ + "host example.com", + "user deploy", + "hostname 10.0.0.5", + "port 2222", + "proxyjump bastion.example.com", + "identityfile ~/.ssh/id_ed25519", + "identityfile ~/.ssh/id_rsa", + "addkeystoagent false", +}, "\n") + +--- Run Diagnostic.test against stubbed ssh invocations +--- @param opts table {host, auth_code, auth_stderr, home_stdout, pids, socket_error} +--- @return table report Lines of the report buffer +--- @return table control Control operations that were run, in order +--- @return table commands Every ssh command the preflight executed +local function run_preflight(opts) + stub.reload() + -- A real writable directory: the preflight refuses to authenticate without one. + local socket_dir = vim.fn.tempname() .. "/sockets" + require("sshfs.config").setup({ connections = { socket_dir = socket_dir } }) + + local control = {} + local commands = {} + local pids = vim.deepcopy(opts.pids or {}) + + -- ssh -O check / -O exit go through vim.fn.system + stub.system(function(cmd) + local operation = nil + for index, argument in ipairs(cmd) do + if argument == "-O" then operation = cmd[index + 1] end + end + table.insert(control, operation) + + if operation == "check" then + -- `false` stands for "no master running" so the list keeps no nil holes. + local pid = table.remove(pids, 1) + if pid then return "Master running (pid=" .. pid .. ")", 0 end + return "Control socket connect: No such file or directory", 255 + end + return "", 0 + end) + + stub.set("schedule", function(fn) + fn() + end) + stub.set("system", function(cmd, _, callback) + table.insert(commands, cmd) + + local result = { code = 0, stdout = "", stderr = "" } + if vim.tbl_contains(cmd, "-G") then + result.stdout = SSH_G_OUTPUT + elseif cmd[#cmd] == "exit" then + result.code = opts.auth_code or 0 + result.stderr = opts.auth_stderr or "" + else + result.stdout = opts.home_stdout or "/home/deploy" + end + + if callback then callback(result) end + return { + wait = function() + return result + end, + } + end) + + if opts.socket_error then + package.loaded["sshfs.lib.ssh"] = setmetatable({ + prepare_socket_dir = function() + return nil, opts.socket_error + end, + }, { __index = require("sshfs.lib.ssh") }) + end + + require("sshfs.diagnostic").test(opts.host or { name = "example.com" }) + + local report = vim.api.nvim_buf_get_lines(0, 0, -1, false) + vim.cmd("bwipeout!") + stub.restore_all() + vim.fn.delete(vim.fn.fnamemodify(socket_dir, ":h"), "rf") + + return report, control, commands +end + +--- Join report lines for substring assertions +local function text(report) + return table.concat(report, "\n") +end + +describe("SSHTest report", function() + it("summarizes the resolved SSH configuration", function() + local report = run_preflight({}) + local body = text(report) + + expect.contains(body, "hostname: 10.0.0.5") + expect.contains(body, "user: deploy") + expect.contains(body, "port: 2222") + expect.contains(body, "proxyjump: bastion.example.com") + end) + + it("joins every identity file into one summary line", function() + local report = run_preflight({}) + expect.contains(text(report), "identityfile: ~/.ssh/id_ed25519, ~/.ssh/id_rsa") + end) + + it("omits the raw ssh -G dump that the summary already covers", function() + local report = run_preflight({}) + local body = text(report) + + expect.falsy(body:find("addkeystoagent", 1, true), "the full resolved config would bury the results") + expect.truthy(#report < 45, "the report stays readable; got " .. #report .. " lines") + end) + + it("marks a successful authentication as passing", function() + local report = run_preflight({}) + expect.contains(text(report), "[PASS] SSH authentication") + end) + + it("reports the exit code and stderr of a failed authentication", function() + local report = run_preflight({ + auth_code = 255, + auth_stderr = "ssh: Could not resolve hostname example.com", + }) + local body = text(report) + + expect.contains(body, "[FAIL] SSH authentication") + expect.contains(body, "Exit code: 255") + expect.contains(body, "Could not resolve hostname") + end) + + it("shows the prospective sshfs command without running it", function() + local report, _, commands = run_preflight({ host = { name = "example.com", path = "/srv/app" } }) + + expect.contains(text(report), "sshfs") + expect.contains(text(report), "") + for _, cmd in ipairs(commands) do + -- Probing `sshfs --version` is fine, and once #20 lands the preflight does + -- exactly that to decide which cache option names to render. Actually + -- invoking sshfs to mount something is what must never happen. + expect.falsy(cmd[1] == "sshfs" and cmd[2] ~= "--version", "the preflight must never execute a mount") + end + end) + + it("explains why no sshfs command is shown without a remote path", function() + local report = run_preflight({}) + + expect.contains(text(report), "Not shown") + expect.falsy(text(report):find("", 1, true)) + end) + + it("resolves a tilde path through the remote home before showing the command", function() + local report = run_preflight({ + host = { name = "example.com", path = "~/projects" }, + home_stdout = "/home/deploy", + }) + + expect.contains(text(report), "/home/deploy/projects") + end) + + it("reports a socket directory failure instead of authenticating", function() + local report = run_preflight({ socket_error = "Failed to create socket directory: permission denied" }) + + expect.contains(text(report), "permission denied") + expect.contains(text(report), "[FAIL] SSH authentication") + end) + + it("renders into a disposable scratch buffer", function() + stub.reload() + require("sshfs.config").setup({}) + stub.system(function() + return "", 255 + end) + stub.set("schedule", function(fn) + fn() + end) + stub.set("system", function(cmd, _, callback) + local result = { code = 0, stdout = vim.tbl_contains(cmd, "-G") and SSH_G_OUTPUT or "", stderr = "" } + if callback then callback(result) end + return { + wait = function() + return result + end, + } + end) + + require("sshfs.diagnostic").test({ name = "example.com" }) + local buf = vim.api.nvim_get_current_buf() + + expect.eq(vim.bo[buf].buftype, "nofile") + expect.eq(vim.bo[buf].bufhidden, "wipe") + expect.falsy(vim.bo[buf].modifiable, "the report is read-only") + expect.falsy(vim.bo[buf].swapfile) + + vim.cmd("bwipeout!") + stub.restore_all() + end) +end) + +describe("SSHTest ControlMaster handling", function() + it("closes a master it created itself", function() + -- No master before, one running afterwards: the preflight created it. + local _, control = run_preflight({ pids = { false, 4242, 4242 } }) + + expect.truthy(vim.tbl_contains(control, "exit"), "a diagnostic connection must not outlive the report") + end) + + it("preserves a master that already existed", function() + local _, control = run_preflight({ pids = { 1111, 1111 } }) + + expect.falsy(vim.tbl_contains(control, "exit"), "an existing shared session must survive the preflight") + end) + + it("does not close a master created by something else mid-test", function() + -- No master before, but authentication failed, so the master that appeared + -- belongs to a concurrent :SSHConnect rather than to this preflight. + local _, control = run_preflight({ auth_code = 255, pids = { false, 9999, 9999 } }) + + expect.falsy(vim.tbl_contains(control, "exit"), "closing another process's connection would break its mount") + end) + + it("does not close a master whose pid changed after the test ran", function() + -- Our master exited and a different one replaced it before cleanup. + local _, control = run_preflight({ pids = { false, 4242, 5555 } }) + + expect.falsy(vim.tbl_contains(control, "exit"), "the socket is no longer the one this preflight created") + end) + + it("leaves no master behind when none was ever created", function() + local _, control = run_preflight({ auth_code = 255, pids = {} }) + + expect.falsy(vim.tbl_contains(control, "exit")) + end) +end) diff --git a/tests/harness.lua b/tests/harness.lua new file mode 100644 index 0000000..2649a3c --- /dev/null +++ b/tests/harness.lua @@ -0,0 +1,165 @@ +-- tests/harness.lua +-- Minimal dependency-free test harness for sshfs.nvim +-- +-- Tests are plain Lua files that call describe/it and the expect helpers. The +-- runner executes them inside headless Neovim so the real vim API is available +-- and only the system-facing calls need stubbing. + +local Harness = {} + +local suites = {} +local current_suite = nil + +--- Register a group of test cases +--- @param name string Suite name +--- @param fn function Function that registers cases with it() +function Harness.describe(name, fn) + local suite = { name = name, cases = {} } + table.insert(suites, suite) + + current_suite = suite + local ok, err = pcall(fn) + current_suite = nil + + if not ok then + -- A suite that fails to register is reported as a single failing case so + -- the runner still exits non-zero instead of silently skipping it. + table.insert(suite.cases, { + name = "", + fn = function() + error(err, 0) + end, + }) + end +end + +--- Register a single test case +--- @param name string Case name +--- @param fn function Test body; failures are raised as errors +function Harness.it(name, fn) + if not current_suite then error("it() called outside of describe()", 2) end + table.insert(current_suite.cases, { name = name, fn = fn }) +end + +local function render(value) + if type(value) == "string" then return string.format("%q", value) end + if type(value) == "table" then return vim.inspect(value) end + return tostring(value) +end + +local function fail(message, level) + error(message, (level or 2) + 1) +end + +Harness.expect = {} + +--- Assert deep equality, comparing tables by value +function Harness.expect.eq(actual, expected, context) + if not vim.deep_equal(actual, expected) then + fail( + string.format("%sexpected %s\n got %s", context and (context .. ": ") or "", render(expected), render(actual)) + ) + end +end + +--- Assert a value is neither nil nor false +function Harness.expect.truthy(value, context) + if not value then + fail(string.format("%sexpected a truthy value, got %s", context and (context .. ": ") or "", render(value))) + end +end + +--- Assert a value is nil or false +function Harness.expect.falsy(value, context) + if value then + fail(string.format("%sexpected a falsy value, got %s", context and (context .. ": ") or "", render(value))) + end +end + +--- Assert a value is exactly nil (distinct from false) +function Harness.expect.is_nil(value, context) + if value ~= nil then + fail(string.format("%sexpected nil, got %s", context and (context .. ": ") or "", render(value))) + end +end + +--- Assert a string contains a plain substring +function Harness.expect.contains(haystack, needle, context) + if type(haystack) ~= "string" or not haystack:find(needle, 1, true) then + fail( + string.format( + "%sexpected %s to contain %s", + context and (context .. ": ") or "", + render(haystack), + render(needle) + ) + ) + end +end + +--- Assert a function raises, optionally matching a plain substring of the error +function Harness.expect.errors(fn, needle, context) + local ok, err = pcall(fn) + if ok then fail(string.format("%sexpected the call to raise an error", context and (context .. ": ") or "")) end + if needle then Harness.expect.contains(tostring(err), needle, context) end +end + +--- Assert a function does not raise, returning every result it produced +function Harness.expect.no_error(fn, context) + local results = { pcall(fn) } + if not results[1] then + fail(string.format("%sexpected no error, got %s", context and (context .. ": ") or "", tostring(results[2]))) + end + return unpack(results, 2, #results) +end + +--- Run every registered suite and report results +--- @return number exit_code 0 when all cases pass +function Harness.run() + -- io.write keeps line breaks deterministic; print() interleaves oddly under `nvim -l` + local function say(text) + io.write((text or "") .. "\n") + end + + local passed, failed = 0, 0 + local failures = {} + + for _, suite in ipairs(suites) do + say(suite.name) + for _, case in ipairs(suite.cases) do + local ok, err = pcall(case.fn) + if ok then + passed = passed + 1 + say(" ok " .. case.name) + else + failed = failed + 1 + say(" FAIL " .. case.name) + table.insert(failures, { name = suite.name .. " :: " .. case.name, err = tostring(err) }) + end + end + end + + say("") + if failed > 0 then + say("Failures:") + for _, failure in ipairs(failures) do + say("") + say(" " .. failure.name) + for line in failure.err:gmatch("[^\r\n]+") do + say(" " .. line) + end + end + say("") + end + + say(string.format("%d passed, %d failed", passed, failed)) + return failed == 0 and 0 or 1 +end + +--- Discard registered suites (used by the runner between files in-process) +function Harness.reset() + suites = {} + current_suite = nil +end + +return Harness diff --git a/tests/mount_point_spec.lua b/tests/mount_point_spec.lua new file mode 100644 index 0000000..052c5a7 --- /dev/null +++ b/tests/mount_point_spec.lua @@ -0,0 +1,146 @@ +-- tests/mount_point_spec.lua +-- Mount table parsing and mount directory handling + +local BASE_DIR = "/home/tester/mnt" + +--- Load MountPoint with a known base directory and a stubbed system mount table +--- @param opts table {mount_output, findmnt_output, findmnt_available} +local function with_mounts(opts) + stub.reload() + require("sshfs.config").setup({ mounts = { base_dir = BASE_DIR } }) + + stub.executable({ findmnt = opts.findmnt_available or false }) + stub.system(function(cmd) + if type(cmd) == "table" and cmd[1] == "findmnt" then return opts.findmnt_output or "", opts.findmnt_code or 0 end + return opts.mount_output or "", 0 + end) + + return require("sshfs.lib.mount_point") +end + +describe("MountPoint.list_active", function() + it("parses Linux fuse.sshfs mount output", function() + local MountPoint = with_mounts({ + mount_output = table.concat({ + "proc on /proc type proc (rw,nosuid)", + "deploy@example.com:/srv/app on " .. BASE_DIR .. "/example type fuse.sshfs (rw,nosuid,nodev)", + }, "\n"), + }) + + expect.eq(MountPoint.list_active(), { + { + host = "example.com", + mount_path = BASE_DIR .. "/example", + remote_path = "/srv/app", + }, + }) + stub.restore_all() + end) + + it("parses macFUSE mount output", function() + local MountPoint = with_mounts({ + mount_output = "deploy@example.com:/srv/app on " .. BASE_DIR .. "/example (macfuse, nodev, nosuid)", + }) + + local mounts = MountPoint.list_active() + expect.eq(#mounts, 1) + expect.eq(mounts[1].host, "example.com") + expect.eq(mounts[1].remote_path, "/srv/app") + stub.restore_all() + end) + + it("parses a remote spec without a user", function() + local MountPoint = with_mounts({ + mount_output = "example.com:/srv/app on " .. BASE_DIR .. "/example type fuse.sshfs (rw)", + }) + + local mounts = MountPoint.list_active() + expect.eq(mounts[1].host, "example.com") + expect.eq(mounts[1].remote_path, "/srv/app") + stub.restore_all() + end) + + it("prefers findmnt output when findmnt is available", function() + local MountPoint = with_mounts({ + findmnt_available = true, + findmnt_output = "deploy@example.com:/srv/app " .. BASE_DIR .. "/example\n", + mount_output = "should-not-be-read on /elsewhere type fuse.sshfs (rw)", + }) + + local mounts = MountPoint.list_active() + expect.eq(#mounts, 1) + expect.eq(mounts[1].mount_path, BASE_DIR .. "/example") + stub.restore_all() + end) + + it("ignores mounts outside the configured base directory", function() + local MountPoint = with_mounts({ + mount_output = "deploy@example.com:/srv/app on /somewhere/else type fuse.sshfs (rw)", + }) + + expect.eq(MountPoint.list_active(), {}) + stub.restore_all() + end) + + it("ignores non-sshfs mount lines", function() + local MountPoint = with_mounts({ + mount_output = table.concat({ + "tmpfs on " .. BASE_DIR .. "/tmp type tmpfs (rw)", + "/dev/sda1 on " .. BASE_DIR .. "/disk type ext4 (rw)", + }, "\n"), + }) + + expect.eq(MountPoint.list_active(), {}) + stub.restore_all() + end) + + it("returns no mounts when the mount command fails", function() + stub.reload() + require("sshfs.config").setup({ mounts = { base_dir = BASE_DIR } }) + stub.executable({}) + stub.system(function() + return "", 1 + end) + + expect.eq(require("sshfs.lib.mount_point").list_active(), {}) + stub.restore_all() + end) +end) + +describe("MountPoint.format_label", function() + it("includes the remote path when it is meaningful", function() + stub.reload() + local MountPoint = require("sshfs.lib.mount_point") + expect.eq(MountPoint.format_label({ host = "example.com", remote_path = "/srv/app" }), "example.com: /srv/app") + end) + + it("omits the remote path for root and empty paths", function() + stub.reload() + local MountPoint = require("sshfs.lib.mount_point") + expect.eq(MountPoint.format_label({ host = "example.com", remote_path = "/" }), "example.com") + expect.eq(MountPoint.format_label({ host = "example.com", remote_path = "" }), "example.com") + expect.eq(MountPoint.format_label({ host = "example.com" }), "example.com") + end) +end) + +describe("MountPoint.get_or_create", function() + it("reports success for a directory that already exists", function() + stub.reload() + local MountPoint = require("sshfs.lib.mount_point") + local temp_dir = vim.fn.tempname() + vim.fn.mkdir(temp_dir, "p") + + expect.truthy(MountPoint.get_or_create(temp_dir)) + vim.fn.delete(temp_dir, "rf") + end) + + it("creates a missing directory", function() + stub.reload() + local MountPoint = require("sshfs.lib.mount_point") + local temp_dir = vim.fn.tempname() .. "/nested/mount" + + expect.truthy(MountPoint.get_or_create(temp_dir)) + expect.eq(vim.fn.isdirectory(temp_dir), 1) + vim.fn.delete(vim.fn.fnamemodify(temp_dir, ":h:h"), "rf") + end) +end) diff --git a/tests/path_spec.lua b/tests/path_spec.lua new file mode 100644 index 0000000..2edb1eb --- /dev/null +++ b/tests/path_spec.lua @@ -0,0 +1,34 @@ +-- tests/path_spec.lua +-- Remote-to-local path mapping used by the picker integrations + +local function load_path() + stub.reload() + return require("sshfs.lib.path") +end + +describe("Path.map_remote_to_relative", function() + it("strips an absolute remote base path", function() + local Path = load_path() + expect.eq(Path.map_remote_to_relative("/srv/app/lib/init.lua", "/srv/app"), "lib/init.lua") + end) + + it("strips a leading ./ for relative searches", function() + local Path = load_path() + expect.eq(Path.map_remote_to_relative("./lib/init.lua", "."), "lib/init.lua") + end) + + it("strips a leading slash when the base does not match", function() + local Path = load_path() + expect.eq(Path.map_remote_to_relative("/other/lib/init.lua", "/srv/app"), "other/lib/init.lua") + end) + + it("returns the base path itself as an empty relative path", function() + local Path = load_path() + expect.eq(Path.map_remote_to_relative("/srv/app", "/srv/app"), "") + end) + + it("leaves an already relative path untouched", function() + local Path = load_path() + expect.eq(Path.map_remote_to_relative("lib/init.lua", "."), "lib/init.lua") + end) +end) diff --git a/tests/run.lua b/tests/run.lua new file mode 100644 index 0000000..fca6f3d --- /dev/null +++ b/tests/run.lua @@ -0,0 +1,43 @@ +-- tests/run.lua +-- Test entry point: `nvim -l tests/run.lua [pattern]` +-- +-- Discovers tests/*_spec.lua, runs them in one headless Neovim instance, and +-- exits non-zero when any case fails so CI can gate on it. + +local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h") +vim.opt.runtimepath:prepend(root) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path + +local Harness = require("tests.harness") +local Stub = require("tests.stub") + +-- describe/it/expect are exposed as globals so spec files stay free of boilerplate +_G.describe = Harness.describe +_G.it = Harness.it +_G.expect = Harness.expect +_G.stub = Stub + +local pattern = arg and arg[1] +local spec_files = vim.fn.globpath(root .. "/tests", "*_spec.lua", false, true) +table.sort(spec_files) + +local loaded = 0 +for _, file in ipairs(spec_files) do + if not pattern or file:find(pattern, 1, true) then + loaded = loaded + 1 + local chunk, err = loadfile(file) + if not chunk then error("could not load " .. file .. ": " .. tostring(err)) end + chunk() + end +end + +if loaded == 0 then + print("no spec files matched" .. (pattern and (" pattern " .. pattern) or "")) + os.exit(1) +end + +-- Every case restores its own stubs, but a case that fails mid-test may not, so +-- the runner clears anything left behind before reporting. +local exit_code = Harness.run() +Stub.restore_all() +os.exit(exit_code) diff --git a/tests/ssh_builders_spec.lua b/tests/ssh_builders_spec.lua new file mode 100644 index 0000000..67448f0 --- /dev/null +++ b/tests/ssh_builders_spec.lua @@ -0,0 +1,183 @@ +-- tests/ssh_builders_spec.lua +-- Shared SSH command builders used by both SSHConnect and SSHTest +-- +-- SSHTest is only trustworthy if it runs the same commands the real connection +-- path runs, so these cover the builders both sides share. + +local SOCKET_DIR = "/home/tester/.ssh/sockets" + +local function load_ssh() + stub.reload() + require("sshfs.config").setup({ connections = { socket_dir = SOCKET_DIR, control_persist = "10m" } }) + return require("sshfs.lib.ssh") +end + +--- Position of a value in a command list, or nil +local function index_of(cmd, value) + for index, argument in ipairs(cmd) do + if argument == value then return index end + end + return nil +end + +describe("Ssh.build_batch_command", function() + it("forces a master, disables prompts, and only tests the connection", function() + local cmd = load_ssh().build_batch_command("example.com") + local joined = table.concat(cmd, " ") + + expect.contains(joined, "ControlMaster=yes") + expect.contains(joined, "BatchMode=yes") + expect.contains(joined, "ControlPath=" .. SOCKET_DIR .. "/%C") + expect.eq(cmd[#cmd], "exit", "the batch probe must not start a shell") + end) + + it("accepts a plain host name", function() + local cmd = load_ssh().build_batch_command("example.com") + expect.eq(cmd[#cmd - 1], "example.com") + end) + + it("propagates an explicit user and port from a host object", function() + local cmd = load_ssh().build_batch_command({ name = "example.com", user = "deploy", port = 2222 }) + + expect.eq(cmd[index_of(cmd, "-l") + 1], "deploy") + expect.eq(cmd[index_of(cmd, "-p") + 1], "2222", "the port must be passed as a string") + expect.eq(cmd[#cmd - 1], "example.com") + end) + + it("omits user and port when the host object does not set them", function() + local cmd = load_ssh().build_batch_command({ name = "example.com" }) + + expect.is_nil(index_of(cmd, "-l")) + expect.is_nil(index_of(cmd, "-p")) + end) +end) + +describe("Ssh.build_home_command", function() + it("reuses the existing socket without renegotiating a master", function() + local cmd = load_ssh().build_home_command("example.com") + local joined = table.concat(cmd, " ") + + expect.contains(joined, "ControlPath=" .. SOCKET_DIR .. "/%C") + expect.falsy(joined:find("ControlMaster", 1, true), "resolving the home directory must reuse the socket") + end) + + it("resolves the canonical home directory with a fallback", function() + local cmd = load_ssh().build_home_command("example.com") + expect.eq(cmd[#cmd], "readlink -f $HOME 2>/dev/null || echo $HOME") + end) + + it("propagates an explicit user and port", function() + local cmd = load_ssh().build_home_command({ name = "example.com", user = "deploy", port = 2222 }) + + expect.eq(cmd[index_of(cmd, "-l") + 1], "deploy") + expect.eq(cmd[index_of(cmd, "-p") + 1], "2222") + end) +end) + +describe("Ssh.build_auth_command", function() + it("forces a master so interactive authentication creates the socket", function() + local cmd = load_ssh().build_auth_command("example.com") + local joined = table.concat(cmd, " ") + + expect.contains(joined, "ControlMaster=yes") + expect.falsy(joined:find("BatchMode", 1, true), "interactive authentication must be able to prompt") + expect.eq(cmd[#cmd], "exit") + end) +end) + +describe("Ssh.build_control_command", function() + it("addresses the socket for a control operation", function() + local cmd = load_ssh().build_control_command("example.com", "check") + + expect.contains(table.concat(cmd, " "), "ControlPath=" .. SOCKET_DIR .. "/%C") + expect.eq(cmd[index_of(cmd, "-O") + 1], "check") + expect.eq(cmd[#cmd], "example.com") + end) + + it("builds the exit operation used for cleanup", function() + local cmd = load_ssh().build_control_command({ name = "example.com", port = 2222 }, "exit") + + expect.eq(cmd[index_of(cmd, "-O") + 1], "exit") + expect.eq(cmd[index_of(cmd, "-p") + 1], "2222") + end) +end) + +describe("Ssh.control_master_pid", function() + it("returns the pid reported by a running master", function() + local Ssh = load_ssh() + stub.system(function() + return "Master running (pid=4242)\r\n", 0 + end) + + local pid = Ssh.control_master_pid("example.com") + stub.restore_all() + + expect.eq(pid, 4242) + end) + + it("returns nil when no master is running", function() + local Ssh = load_ssh() + stub.system(function() + return "Control socket connect(/home/tester/.ssh/sockets/abc): No such file or directory", 255 + end) + + local pid = Ssh.control_master_pid("example.com") + stub.restore_all() + + expect.is_nil(pid) + end) + + it("returns nil when the check succeeds without a parseable pid", function() + local Ssh = load_ssh() + stub.system(function() + return "unexpected output", 0 + end) + + local pid = Ssh.control_master_pid("example.com") + stub.restore_all() + + expect.is_nil(pid) + end) +end) + +describe("Ssh.get_remote_home", function() + it("accepts a host object and reports the resolved home", function() + local Ssh = load_ssh() + stub.set("schedule", function(fn) + fn() + end) + stub.set("system", function(_, _, callback) + callback({ code = 0, stdout = "/home/deploy\n", stderr = "" }) + return { wait = function() end } + end) + + local home, err = nil, nil + Ssh.get_remote_home({ name = "example.com", user = "deploy" }, function(resolved, error_message) + home, err = resolved, error_message + end) + stub.restore_all() + + expect.eq(home, "/home/deploy") + expect.is_nil(err) + end) + + it("rejects output that is not an absolute path", function() + local Ssh = load_ssh() + stub.set("schedule", function(fn) + fn() + end) + stub.set("system", function(_, _, callback) + callback({ code = 0, stdout = "not-a-path", stderr = "" }) + return { wait = function() end } + end) + + local home, err = nil, nil + Ssh.get_remote_home("example.com", function(resolved, error_message) + home, err = resolved, error_message + end) + stub.restore_all() + + expect.is_nil(home) + expect.contains(err, "invalid") + end) +end) diff --git a/tests/ssh_config_spec.lua b/tests/ssh_config_spec.lua new file mode 100644 index 0000000..55d9ac3 --- /dev/null +++ b/tests/ssh_config_spec.lua @@ -0,0 +1,55 @@ +-- tests/ssh_config_spec.lua +-- Ad-hoc host string parsing used by :SSHConnect and friends + +local function load_ssh_config() + stub.reload() + return require("sshfs.lib.ssh_config") +end + +describe("SSHConfig.parse_host", function() + it("parses a bare host alias", function() + local host = load_ssh_config().parse_host("production") + + expect.eq(host.name, "production") + expect.is_nil(host.user) + expect.is_nil(host.path) + expect.is_nil(host.port) + end) + + it("parses user@host", function() + local host = load_ssh_config().parse_host("deploy@example.com") + + expect.eq(host.name, "example.com") + expect.eq(host.user, "deploy") + expect.is_nil(host.path) + end) + + it("parses user@host:path", function() + local host = load_ssh_config().parse_host("deploy@example.com:/srv/app") + + expect.eq(host.name, "example.com") + expect.eq(host.user, "deploy") + expect.eq(host.path, "/srv/app") + end) + + it("parses host:path without a user", function() + local host = load_ssh_config().parse_host("example.com:/srv/app") + + expect.eq(host.name, "example.com") + expect.is_nil(host.user) + expect.eq(host.path, "/srv/app") + end) + + it("extracts an explicit port and keeps it out of the host name", function() + local host = load_ssh_config().parse_host("deploy@example.com:/srv/app -p 2222") + + expect.eq(host.port, "2222") + expect.eq(host.name, "example.com") + expect.eq(host.path, "/srv/app") + end) + + it("treats an empty path as absent", function() + local host = load_ssh_config().parse_host("example.com:") + expect.is_nil(host.path) + end) +end) diff --git a/tests/ssh_spec.lua b/tests/ssh_spec.lua new file mode 100644 index 0000000..d9e555a --- /dev/null +++ b/tests/ssh_spec.lua @@ -0,0 +1,92 @@ +-- tests/ssh_spec.lua +-- SSH command construction + +local SOCKET_DIR = "/home/tester/.ssh/sockets" +local CONTROL_PATH = "ControlPath=" .. SOCKET_DIR .. "/%C" + +local function load_ssh() + stub.reload() + require("sshfs.config").setup({ connections = { socket_dir = SOCKET_DIR, control_persist = "10m" } }) + return require("sshfs.lib.ssh") +end + +describe("Ssh.build_command_string", function() + it("passes only the control path when reusing a socket", function() + local Ssh = load_ssh() + expect.eq(Ssh.build_command_string("socket"), "ssh -o " .. CONTROL_PATH) + end) + + it("forces a master and disables prompts for batch connections", function() + local Ssh = load_ssh() + local command = Ssh.build_command_string("batch") + + expect.contains(command, "ControlMaster=yes") + expect.contains(command, CONTROL_PATH) + expect.contains(command, "BatchMode=yes") + end) + + it("uses an automatic master for interactive sessions", function() + local Ssh = load_ssh() + local command = Ssh.build_command_string(nil) + + expect.contains(command, "ControlMaster=auto") + expect.contains(command, "ControlPersist=10m") + expect.falsy(command:find("BatchMode", 1, true), "interactive sessions must stay interactive") + end) +end) + +describe("Ssh.build_command", function() + it("returns a command list rather than a shell string", function() + local Ssh = load_ssh() + local cmd = Ssh.build_command("example.com", nil) + + expect.eq(cmd[1], "ssh") + expect.eq(cmd[#cmd], "example.com") + end) + + it("requests a tty and a login shell when a remote path is given", function() + local Ssh = load_ssh() + local cmd = Ssh.build_command("example.com", "/srv/app") + + expect.eq(cmd[#cmd - 1], "-t") + expect.eq(cmd[#cmd], "cd '/srv/app' && exec $SHELL -l") + end) + + it("expands a bare tilde through the remote shell", function() + local Ssh = load_ssh() + local cmd = Ssh.build_command("example.com", "~") + expect.eq(cmd[#cmd], "cd ~ && exec $SHELL -l") + end) + + it("expands the home prefix while quoting the remainder", function() + local Ssh = load_ssh() + local cmd = Ssh.build_command("example.com", "~/projects/app") + expect.eq(cmd[#cmd], "cd ~ && cd 'projects/app' && exec $SHELL -l") + end) + + it("escapes single quotes in remote paths", function() + local Ssh = load_ssh() + local cmd = Ssh.build_command("example.com", "/srv/it's here") + + expect.contains(cmd[#cmd], "'/srv/it'\\''s here'") + end) +end) + +describe("Ssh.cleanup_control_master", function() + it("sends an exit control command for the host", function() + local Ssh = load_ssh() + local received = nil + stub.system(function(cmd) + received = cmd + return "" + end) + + Ssh.cleanup_control_master("example.com") + stub.restore_all() + + expect.eq(received[1], "ssh") + expect.eq(received[#received], "example.com") + expect.eq(received[#received - 2], "-O") + expect.eq(received[#received - 1], "exit") + end) +end) diff --git a/tests/stub.lua b/tests/stub.lua new file mode 100644 index 0000000..607fe0b --- /dev/null +++ b/tests/stub.lua @@ -0,0 +1,152 @@ +-- tests/stub.lua +-- Stubbing helpers for the system-facing calls sshfs.nvim makes +-- +-- Unit tests must never touch a real SSH server or mount table, so the few +-- entry points that reach the operating system (vim.fn.system, vim.fn.executable, +-- vim.system, vim.uv.fs_stat, vim.notify) are replaced per test and restored +-- afterwards by Stub.restore_all(). + +local Stub = {} + +local restorers = {} + +local function split_path(path) + local parts = {} + for part in path:gmatch("[^%.]+") do + table.insert(parts, part) + end + return parts +end + +--- Replace a dotted field under `vim` (e.g. "fn.system") for the current test +--- @param path string Dotted path relative to the vim table +--- @param value any Replacement value +function Stub.set(path, value) + local parts = split_path(path) + local target = vim + for index = 1, #parts - 1 do + target = target[parts[index]] + end + + local key = parts[#parts] + local original = target[key] + target[key] = value + table.insert(restorers, function() + target[key] = original + end) +end + +--- Replace vim.v so tests can control v:shell_error, which is read-only +--- @param shell_error number Value reported to code that inspects vim.v.shell_error +function Stub.shell_error(shell_error) + local original = vim.v + vim.v = setmetatable({ shell_error = shell_error }, { __index = original }) + table.insert(restorers, function() + vim.v = original + end) +end + +--- Stub vim.fn.system with a handler and set the resulting v:shell_error +--- The handler receives the command (string or list) and returns output plus an +--- optional exit code, defaulting to 0. +--- @param handler function fun(cmd): string, number|nil +function Stub.system(handler) + local shell_error = 0 + Stub.set("fn.system", function(cmd) + local output, code = handler(cmd) + shell_error = code or 0 + return output or "" + end) + + local original = vim.v + vim.v = setmetatable({}, { + __index = function(_, key) + if key == "shell_error" then return shell_error end + return original[key] + end, + }) + table.insert(restorers, function() + vim.v = original + end) +end + +--- Stub vim.fn.executable from a set of available command names +--- @param available table Map or list of command names that should report as present +function Stub.executable(available) + local lookup = {} + if vim.islist(available) then + for _, name in ipairs(available) do + lookup[name] = true + end + else + lookup = available + end + + Stub.set("fn.executable", function(name) + return lookup[name] and 1 or 0 + end) +end + +--- Build a vim.system replacement +--- The handler receives the command list and returns a result table with code, +--- stdout, and stderr. The stub supports both call styles used in the plugin: +--- an async callback and a synchronous `:wait()` on the returned object. +--- @param handler function fun(cmd): table +--- @return table calls List of command lists the stub received +function Stub.vim_system(handler) + local calls = {} + + Stub.set("system", function(cmd, _, callback) + table.insert(calls, cmd) + local result = handler(cmd) or {} + result.code = result.code or 0 + result.stdout = result.stdout or "" + result.stderr = result.stderr or "" + + if callback then + callback(result) + return { + wait = function() + return result + end, + } + end + + return { + wait = function() + return result + end, + } + end) + + return calls +end + +--- Capture vim.notify calls instead of printing them +--- @return table notifications List of {message, level} tables +function Stub.notifications() + local captured = {} + Stub.set("notify", function(message, level) + table.insert(captured, { message = message, level = level }) + end) + return captured +end + +--- Drop cached sshfs.nvim modules so per-module state is rebuilt +--- Several modules memoize (SSHFS version detection, config options), so tests +--- that exercise that state must start from a clean load. +function Stub.reload() + for name in pairs(package.loaded) do + if name:match("^sshfs") then package.loaded[name] = nil end + end +end + +--- Undo every stub applied since the last restore +function Stub.restore_all() + for index = #restorers, 1, -1 do + restorers[index]() + end + restorers = {} +end + +return Stub