Files
filepuff-mcp/internal/config/config_test.go
T
lukaszraczylo 9205b2bc26 feat(docs, ci, config): add comprehensive documentation and tooling
- [x] Add API reference documentation with tool descriptions and examples
- [x] Add ERROR_CODES reference with error descriptions and remediation steps
- [x] Add PERFORMANCE tuning guide with caching and optimization details
- [x] Add GitHub Actions workflows for linting and security scanning
- [x] Add golangci-lint configuration with comprehensive linter settings
- [x] Add pre-commit hooks configuration for local development
- [x] Add API documentation generator tool (cmd/docgen)
- [x] Update Go version from 1.24 to 1.25 across workflows
- [x] Add static build configuration to goreleaser
- [x] Add metrics package with Prometheus-style metric types
- [x] Add parser benchmarks for performance testing
- [x] Add LSP manager integration tests
- [x] Add server integration tests with MCP protocol flow testing
- [x] Extract regex cache to shared utility package
- [x] Add context cancellation handling in AST queries
- [x] Add graceful shutdown with timeout to server
- [x] Add configurable max parse size (MaxParseSize)
- [x] Add Config.Validate() method with comprehensive checks
- [x] Add parser cache statistics tracking
- [x] Add file permission preservation in edit operations
- [x] Improve line splitting for large files with bufio.Scanner
- [x] Add comprehensive config tests for edge cases
- [x] Update Makefile with new targets and documentation
2026-02-18 21:49:54 +00:00

563 lines
15 KiB
Go

package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDefault(t *testing.T) {
cfg := Default()
if cfg.WorkspaceRoot != "." {
t.Errorf("expected default workspace root '.', got %q", cfg.WorkspaceRoot)
}
if cfg.LSPTimeout != DefaultLSPTimeout {
t.Errorf("expected default LSP timeout %v, got %v", DefaultLSPTimeout, cfg.LSPTimeout)
}
if cfg.SearchTimeout != DefaultSearchTimeout {
t.Errorf("expected default search timeout %v, got %v", DefaultSearchTimeout, cfg.SearchTimeout)
}
if cfg.MaxFileSize != DefaultMaxFileSize {
t.Errorf("expected default max file size %d, got %d", DefaultMaxFileSize, cfg.MaxFileSize)
}
if cfg.MaxSearchResults != DefaultMaxSearchResults {
t.Errorf("expected default max search results %d, got %d", DefaultMaxSearchResults, cfg.MaxSearchResults)
}
if cfg.MaxEditSize != DefaultMaxEditSize {
t.Errorf("expected default max edit size %d, got %d", DefaultMaxEditSize, cfg.MaxEditSize)
}
if !cfg.EnableLSP {
t.Error("expected EnableLSP to be true by default")
}
if !cfg.FollowSymlinks {
t.Error("expected FollowSymlinks to be true by default")
}
if !cfg.RespectGitignore {
t.Error("expected RespectGitignore to be true by default")
}
}
func TestLoad(t *testing.T) {
// Create a temporary directory for workspace
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
cfg, err := Load(tmpDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
absPath, _ := filepath.Abs(tmpDir)
if cfg.WorkspaceRoot != absPath {
t.Errorf("expected workspace root %q, got %q", absPath, cfg.WorkspaceRoot)
}
}
func TestLoadFromEnv(t *testing.T) {
// Save original env values
origLSPTimeout := os.Getenv("MCP_LSP_TIMEOUT")
origSearchTimeout := os.Getenv("MCP_SEARCH_TIMEOUT")
origEnableLSP := os.Getenv("MCP_ENABLE_LSP")
origFollowSymlinks := os.Getenv("MCP_FOLLOW_SYMLINKS")
origRespectGitignore := os.Getenv("MCP_RESPECT_GITIGNORE")
// Restore env after test
t.Cleanup(func() {
_ = os.Setenv("MCP_LSP_TIMEOUT", origLSPTimeout)
_ = os.Setenv("MCP_SEARCH_TIMEOUT", origSearchTimeout)
_ = os.Setenv("MCP_ENABLE_LSP", origEnableLSP)
_ = os.Setenv("MCP_FOLLOW_SYMLINKS", origFollowSymlinks)
_ = os.Setenv("MCP_RESPECT_GITIGNORE", origRespectGitignore)
})
// Set test env values
_ = os.Setenv("MCP_LSP_TIMEOUT", "10m")
_ = os.Setenv("MCP_SEARCH_TIMEOUT", "1m")
_ = os.Setenv("MCP_ENABLE_LSP", "false")
_ = os.Setenv("MCP_FOLLOW_SYMLINKS", "0")
_ = os.Setenv("MCP_RESPECT_GITIGNORE", "false")
cfg := Default()
cfg.loadFromEnv()
if cfg.LSPTimeout != 10*time.Minute {
t.Errorf("expected LSP timeout 10m, got %v", cfg.LSPTimeout)
}
if cfg.SearchTimeout != 1*time.Minute {
t.Errorf("expected search timeout 1m, got %v", cfg.SearchTimeout)
}
if cfg.EnableLSP {
t.Error("expected EnableLSP to be false")
}
if cfg.FollowSymlinks {
t.Error("expected FollowSymlinks to be false")
}
if cfg.RespectGitignore {
t.Error("expected RespectGitignore to be false")
}
}
func TestIsPathAllowed(t *testing.T) {
// Create a temporary directory
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
cfg := Default()
cfg.WorkspaceRoot = tmpDir
tests := []struct {
name string
path string
allowed bool
}{
{
name: "file in workspace",
path: filepath.Join(tmpDir, "test.go"),
allowed: true,
},
{
name: "nested file in workspace",
path: filepath.Join(tmpDir, "subdir", "test.go"),
allowed: true,
},
{
name: "path outside workspace",
path: "/etc/passwd",
allowed: false,
},
{
name: "relative path traversal",
path: filepath.Join(tmpDir, "..", "outside.txt"),
allowed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cfg.IsPathAllowed(tt.path)
if result != tt.allowed {
t.Errorf("IsPathAllowed(%q) = %v, want %v", tt.path, result, tt.allowed)
}
})
}
}
func TestLoadWithConfigFile(t *testing.T) {
// Create a temporary directory
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Write config file
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
configContent := `{
"enable_lsp": false,
"follow_symlinks": false
}`
err = os.WriteFile(configPath, []byte(configContent), 0o600)
if err != nil {
t.Fatalf("failed to write config file: %v", err)
}
var cfg *Config
cfg, err = Load(tmpDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.EnableLSP {
t.Error("expected EnableLSP to be false from config file")
}
if cfg.FollowSymlinks {
t.Error("expected FollowSymlinks to be false from config file")
}
}
// TestValidate tests the Validate method with various inputs.
func TestValidate(t *testing.T) {
tests := []struct {
name string
cfg *Config
expectErr bool
errMsg string
}{
{
name: "valid_config",
cfg: Default(),
expectErr: false,
},
{
name: "invalid_max_file_size",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: -1,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "max_file_size must be positive",
},
{
name: "zero_max_file_size",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: 0,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "max_file_size must be positive",
},
{
name: "invalid_max_parse_size",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: -1,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "max_parse_size must be positive",
},
{
name: "zero_max_parse_size",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: 0,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "max_parse_size must be positive",
},
{
name: "invalid_lsp_timeout",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: -1 * time.Second,
},
expectErr: true,
errMsg: "lsp_timeout must be positive",
},
{
name: "zero_lsp_timeout",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: 0,
},
expectErr: true,
errMsg: "lsp_timeout must be positive",
},
{
name: "nonexistent_workspace",
cfg: &Config{
WorkspaceRoot: "/nonexistent/path/that/does/not/exist",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "workspace_root does not exist",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.cfg.Validate()
if tt.expectErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.errMsg)
} else if !contains(err.Error(), tt.errMsg) {
t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
// TestValidateWithFile tests validation with an actual file as workspace root.
func TestValidateWithFile(t *testing.T) {
// Create a temporary file
tmpFile, err := os.CreateTemp("", "test-file-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
_ = tmpFile.Close()
t.Cleanup(func() { _ = os.Remove(tmpFile.Name()) })
cfg := &Config{
WorkspaceRoot: tmpFile.Name(),
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: DefaultLSPTimeout,
}
err = cfg.Validate()
if err == nil {
t.Error("expected error when workspace_root is a file, got nil")
} else if !contains(err.Error(), "is not a directory") {
t.Errorf("expected error about not being a directory, got: %v", err)
}
}
// TestLoadEnvironmentPrecedence tests environment variable precedence.
func TestLoadEnvironmentPrecedence(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Write a config file with specific values
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
configContent := `{
"enable_lsp": false,
"follow_symlinks": false,
"lsp_timeout": 60000000000
}`
if err := os.WriteFile(configPath, []byte(configContent), 0o600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Save and restore environment variables
origEnableLSP := os.Getenv("MCP_ENABLE_LSP")
origLSPTimeout := os.Getenv("MCP_LSP_TIMEOUT")
t.Cleanup(func() {
_ = os.Setenv("MCP_ENABLE_LSP", origEnableLSP)
_ = os.Setenv("MCP_LSP_TIMEOUT", origLSPTimeout)
})
// Set environment variables that should override config file
_ = os.Setenv("MCP_ENABLE_LSP", "false")
_ = os.Setenv("MCP_LSP_TIMEOUT", "2m")
cfg, err := Load(tmpDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// Environment variable should override config file
if cfg.LSPTimeout != 2*time.Minute {
t.Errorf("expected LSP timeout 2m from env, got %v", cfg.LSPTimeout)
}
if cfg.EnableLSP {
t.Error("expected EnableLSP to be false from env")
}
// Value from config file (not overridden by env)
if cfg.FollowSymlinks {
t.Error("expected FollowSymlinks to be false from config file")
}
}
// TestIsPathAllowedEdgeCases tests edge cases in path validation.
func TestIsPathAllowedEdgeCases(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
cfg := Default()
cfg.WorkspaceRoot = tmpDir
tests := []struct {
name string
path string
allowed bool
desc string
}{
{
name: "workspace_root_itself",
path: tmpDir,
allowed: false,
desc: "workspace root itself should not be allowed",
},
{
name: "dot_relative",
path: ".",
allowed: false,
desc: "current directory should not be allowed",
},
{
name: "empty_path",
path: "",
allowed: false,
desc: "empty path should not be allowed",
},
{
name: "path_with_double_dots",
path: filepath.Join(tmpDir, "..", filepath.Base(tmpDir), "file.txt"),
allowed: true,
desc: "path with .. that resolves back inside workspace should be allowed",
},
{
name: "deeply_nested_valid",
path: filepath.Join(tmpDir, "a", "b", "c", "file.txt"),
allowed: true,
desc: "deeply nested path should be allowed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cfg.IsPathAllowed(tt.path)
if result != tt.allowed {
t.Errorf("%s: IsPathAllowed(%q) = %v, want %v", tt.desc, tt.path, result, tt.allowed)
}
})
}
}
// TestIsPathAllowedWithSymlinks tests path validation with symbolic links.
func TestIsPathAllowedWithSymlinks(t *testing.T) {
// Create temporary directories
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
realDir := filepath.Join(tmpDir, "real")
if err := os.MkdirAll(realDir, 0o755); err != nil {
t.Fatalf("failed to create real dir: %v", err)
}
// Create a symlink inside workspace
symlinkPath := filepath.Join(tmpDir, "link")
if err := os.Symlink(realDir, symlinkPath); err != nil {
t.Skip("symlink creation not supported on this system")
}
cfg := Default()
cfg.WorkspaceRoot = tmpDir
// File accessed through symlink should be allowed
fileViaSymlink := filepath.Join(symlinkPath, "test.txt")
if !cfg.IsPathAllowed(fileViaSymlink) {
t.Error("file accessed through symlink inside workspace should be allowed")
}
// Direct access should also work
fileDirect := filepath.Join(realDir, "test.txt")
if !cfg.IsPathAllowed(fileDirect) {
t.Error("file accessed directly should be allowed")
}
}
// TestLoadDefaultValues tests that default values are properly set.
func TestLoadDefaultValues(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Clear any environment variables that might affect defaults
origVars := []struct{ key, val string }{
{"MCP_ENABLE_LSP", os.Getenv("MCP_ENABLE_LSP")},
{"MCP_FOLLOW_SYMLINKS", os.Getenv("MCP_FOLLOW_SYMLINKS")},
{"MCP_RESPECT_GITIGNORE", os.Getenv("MCP_RESPECT_GITIGNORE")},
}
t.Cleanup(func() {
for _, v := range origVars {
_ = os.Setenv(v.key, v.val)
}
})
for _, v := range origVars {
_ = os.Unsetenv(v.key)
}
cfg, err := Load(tmpDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// Verify all default values
if cfg.LSPTimeout != DefaultLSPTimeout {
t.Errorf("expected LSPTimeout %v, got %v", DefaultLSPTimeout, cfg.LSPTimeout)
}
if cfg.SearchTimeout != DefaultSearchTimeout {
t.Errorf("expected SearchTimeout %v, got %v", DefaultSearchTimeout, cfg.SearchTimeout)
}
if cfg.MaxFileSize != DefaultMaxFileSize {
t.Errorf("expected MaxFileSize %d, got %d", DefaultMaxFileSize, cfg.MaxFileSize)
}
if cfg.MaxParseSize != DefaultMaxParseSize {
t.Errorf("expected MaxParseSize %d, got %d", DefaultMaxParseSize, cfg.MaxParseSize)
}
if cfg.MaxSearchResults != DefaultMaxSearchResults {
t.Errorf("expected MaxSearchResults %d, got %d", DefaultMaxSearchResults, cfg.MaxSearchResults)
}
if cfg.MaxEditSize != DefaultMaxEditSize {
t.Errorf("expected MaxEditSize %d, got %d", DefaultMaxEditSize, cfg.MaxEditSize)
}
if !cfg.EnableLSP {
t.Error("expected EnableLSP to be true by default")
}
if !cfg.FollowSymlinks {
t.Error("expected FollowSymlinks to be true by default")
}
if !cfg.RespectGitignore {
t.Error("expected RespectGitignore to be true by default")
}
if cfg.Formatters == nil {
t.Error("expected Formatters map to be initialized")
}
}
// TestConfigFileLoadingErrors tests error handling during config file loading.
func TestConfigFileLoadingErrors(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Write invalid JSON
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
invalidJSON := `{"enable_lsp": invalid_value}`
if err := os.WriteFile(configPath, []byte(invalidJSON), 0o600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
_, err = Load(tmpDir)
if err == nil {
t.Error("expected error when loading invalid JSON config file")
}
}
// Helper function to check if a string contains a substring.
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && containsHelper(s, substr)))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}