Files
f2b/cmd/cmd_root_test.go
Ismo Vuorinen 98b53d84b5 fix: repair Renovate config, convert Makefile to go run, update GitHub Actions (#120)
* fix: repair Renovate config and convert Makefile to go run (#117)

- Remove non-existent `github>renovatebot/presets:golang` preset that
  broke Renovate PR creation
- Replace deprecated `fileMatch` with `managerFilePatterns` in
  customManagers
- Rewrite regex to match new Makefile pattern (renovate comment above
  version variable assignment)
- Fix `matchFileNames` glob pattern (`*.mk` -> `**/*.mk`)
- Convert all tool invocations from `go install` + global binary to
  `go run tool@version` for reproducible builds
- Convert npm global tools to `npx --yes` invocations
- Remove `dev-deps` and `check-deps` targets (tools auto-download)
- Add mdformat pre-commit hook with GFM support and config
- Add `fmt-md` Makefile target for manual markdown formatting
- Update local golangci-lint pre-commit hook to use `go run`
- Apply golangci-lint v2.10.1 auto-fixes (fmt.Fprintf optimization)
- Add nolint:gosec annotations for legitimate exec.Command usage
- Exclude .serena/ from mdformat and megalinter
- Add markdown indent_size=unset in .editorconfig for CommonMark compat

* chore(deps): update GitHub Actions to latest versions

- anthropics/claude-code-action: v1.0.34 -> v1.0.64
- actions/setup-go: v6.2.0 -> v6.3.0
- actions/upload-artifact: v6.0.0 -> v7.0.0
- goreleaser/goreleaser-action: v6.4.0 -> v7.0.0
- docker/login-action: v3.6.0 -> v3.7.0
- ivuorinen/actions: v2026.01.21 -> v2026.02.24

* fix: address code review feedback

- Fix issue template YAML frontmatter (replace underscore separators
  with proper --- delimiters); exclude templates from mdformat
- Replace string(rune(n)) with strconv.Itoa(n) in test files to produce
  deterministic numeric directory names instead of Unicode characters
- Remove stale `make dev-deps` reference in README, replace with
  `make dev-setup`
- Extract ban/unban format strings into shared.MetricsFmtBanOperations
  and shared.MetricsFmtUnbanOperations constants
- Replace hardcoded coverage percentages in README with evergreen
  phrasing

* fix: address round 2 code review feedback for PR #120

- Fix corrupted path traversal example in docs/security.md
- Fix Renovate .mk regex to match nested paths (.*\.mk$)
- Update checkmake pre-commit hook to v0.3.2 to match Makefile
- Add sync.WaitGroup to unsynchronized goroutines in security tests
- Fix fmt-md target to use pre-commit run mdformat
- Pin markdownlint-cli2 to v0.21.0 in lint-md target
- Standardize //nolint:gosec to // #nosec annotations for gosec CLI

* fix(ci): install PyYAML dependency for PR lint workflow

The pr-lint workflow uses ivuorinen/actions/pr-lint which internally
calls validate-inputs running a Python script that imports yaml.
Python was set up but PyYAML was never installed, causing
ModuleNotFoundError at runtime.

* fix: address round 3 code review feedback for PR #120

- Wrap Windows-style path traversal example in backtick code span so
  backslashes render literally in docs/security.md
- Add Renovate-managed MARKDOWNLINT_CLI2_VERSION variable in Makefile
  to match the pattern used by all other tool versions
2026-03-01 19:09:17 +02:00

785 lines
19 KiB
Go

package cmd
import (
"bytes"
"context"
"os"
"strings"
"testing"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/ivuorinen/f2b/fail2ban"
)
func TestParseLogLevel(t *testing.T) {
tests := []struct {
name string
level string
expected logrus.Level
}{
{
name: "debug level",
level: "debug",
expected: logrus.DebugLevel,
},
{
name: "info level",
level: "info",
expected: logrus.InfoLevel,
},
{
name: "warn level",
level: "warn",
expected: logrus.WarnLevel,
},
{
name: "warning level",
level: "warning",
expected: logrus.WarnLevel,
},
{
name: "error level",
level: "error",
expected: logrus.ErrorLevel,
},
{
name: "fatal level",
level: "fatal",
expected: logrus.FatalLevel,
},
{
name: "panic level",
level: "panic",
expected: logrus.PanicLevel,
},
{
name: "unknown level defaults to info",
level: "unknown",
expected: logrus.InfoLevel,
},
{
name: "empty level defaults to info",
level: "",
expected: logrus.InfoLevel,
},
{
name: "uppercase level",
level: "DEBUG",
expected: logrus.InfoLevel, // case sensitive, so falls back to default
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseLogLevel(tt.level)
if result != tt.expected {
t.Errorf("parseLogLevel(%q) = %v, want %v", tt.level, result, tt.expected)
}
})
}
}
func TestConfigDefaults(t *testing.T) {
// Test that Config struct has reasonable defaults
config := Config{}
// Initially empty
if config.LogDir != "" {
t.Errorf("expected empty LogDir, got %q", config.LogDir)
}
if config.FilterDir != "" {
t.Errorf("expected empty FilterDir, got %q", config.FilterDir)
}
if config.Format != "" {
t.Errorf("expected empty Format, got %q", config.Format)
}
}
func TestEnvironmentVariableSetup(t *testing.T) {
// Save original environment
// Set up environment variables using t.Setenv for automatic cleanup
t.Setenv("F2B_LOG_DIR", os.Getenv("F2B_LOG_DIR"))
t.Setenv("F2B_FILTER_DIR", os.Getenv("F2B_FILTER_DIR"))
t.Setenv("F2B_LOG_LEVEL", os.Getenv("F2B_LOG_LEVEL"))
t.Setenv("F2B_LOG_FILE", os.Getenv("F2B_LOG_FILE"))
tests := []struct {
name string
envVar string
envValue string
expected string
}{
{
name: "F2B_LOG_DIR environment variable",
envVar: "F2B_LOG_DIR",
envValue: "/custom/log/dir",
expected: "/custom/log/dir",
},
{
name: "F2B_FILTER_DIR environment variable",
envVar: "F2B_FILTER_DIR",
envValue: "/custom/filter/dir",
expected: "/custom/filter/dir",
},
{
name: "F2B_LOG_LEVEL environment variable",
envVar: "F2B_LOG_LEVEL",
envValue: "debug",
expected: "debug",
},
{
name: "F2B_LOG_FILE environment variable",
envVar: "F2B_LOG_FILE",
envValue: "/tmp/f2b.log",
expected: "/tmp/f2b.log",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set environment variable using t.Setenv for automatic cleanup
t.Setenv(tt.envVar, tt.envValue)
// Get the value
result := os.Getenv(tt.envVar)
if result != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, result)
}
})
}
}
func TestConfigStructure(t *testing.T) {
config := Config{
LogDir: "/test/log",
FilterDir: "/test/filter",
Format: "json",
}
if config.LogDir != "/test/log" {
t.Errorf("expected LogDir '/test/log', got %q", config.LogDir)
}
if config.FilterDir != "/test/filter" {
t.Errorf("expected FilterDir '/test/filter', got %q", config.FilterDir)
}
if config.Format != "json" {
t.Errorf("expected Format 'json', got %q", config.Format)
}
}
func TestCompletionCmdStructure(t *testing.T) {
cmd := completionCmd()
if cmd.Use != "completion [bash|zsh|fish|powershell]" {
t.Errorf("unexpected completion command Use: %q", cmd.Use)
}
if cmd.Short != "Generate shell completion scripts" {
t.Errorf("unexpected completion command Short: %q", cmd.Short)
}
expectedValidArgs := []string{"bash", "zsh", "fish", "powershell"}
if len(cmd.ValidArgs) != len(expectedValidArgs) {
t.Errorf("expected %d ValidArgs, got %d", len(expectedValidArgs), len(cmd.ValidArgs))
}
for i, expected := range expectedValidArgs {
if i >= len(cmd.ValidArgs) || cmd.ValidArgs[i] != expected {
t.Errorf("expected ValidArgs[%d] = %q, got %q", i, expected, cmd.ValidArgs[i])
}
}
if !cmd.DisableFlagsInUseLine {
t.Errorf("expected DisableFlagsInUseLine to be true")
}
}
func TestGlobalVariables(t *testing.T) {
// Test that global variables are properly initialized
if rootCmd == nil {
t.Fatal("rootCmd should be initialized")
}
if rootCmd.Use != "f2b" {
t.Errorf("expected rootCmd.Use to be 'f2b', got %q", rootCmd.Use)
}
if rootCmd.Short != "Fail2Ban CLI helper" {
t.Errorf("expected rootCmd.Short to be 'Fail2Ban CLI helper', got %q", rootCmd.Short)
}
expectedLong := "Fail2Ban CLI tool implemented in Go using Cobra."
if rootCmd.Long != expectedLong {
t.Errorf("expected rootCmd.Long to be %q, got %q", expectedLong, rootCmd.Long)
}
}
// BenchmarkParseLogLevel benchmarks the log level parsing function
func BenchmarkParseLogLevel(b *testing.B) {
levels := []string{"debug", "info", "warn", "error", "unknown"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
level := levels[i%len(levels)]
parseLogLevel(level)
}
}
// TestDefaultValues tests the default values used in the configuration
func TestDefaultValues(t *testing.T) {
// Clear environment variables for this test using t.Setenv
t.Setenv("F2B_LOG_DIR", "")
t.Setenv("F2B_FILTER_DIR", "")
// Test default values when environment variables are not set
logDir := os.Getenv("F2B_LOG_DIR")
if logDir != "" {
t.Errorf("expected empty F2B_LOG_DIR, got %q", logDir)
}
filterDir := os.Getenv("F2B_FILTER_DIR")
if filterDir != "" {
t.Errorf("expected empty F2B_FILTER_DIR, got %q", filterDir)
}
}
func TestExecute(t *testing.T) {
tests := []struct {
name string
setupClient func() fail2ban.Client
config Config
wantError bool
}{
{
name: "successful execution with mock client",
setupClient: func() fail2ban.Client {
return fail2ban.NewMockClient()
},
config: Config{
LogDir: "/tmp/test",
FilterDir: "/tmp/filters",
Format: "plain",
},
wantError: false,
},
{
name: "execution with json format",
setupClient: func() fail2ban.Client {
return fail2ban.NewMockClient()
},
config: Config{
LogDir: "/var/log",
FilterDir: "/etc/fail2ban/filter.d",
Format: "json",
},
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := tt.setupClient()
// Capture stdout to prevent output during tests
oldStdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("failed to create pipe: %v", err)
}
os.Stdout = w
// Set up a simple test command that will exit quickly
originalArgs := os.Args
os.Args = []string{"f2b", "version"}
err = Execute(client, tt.config)
// Restore stdout
if err := w.Close(); err != nil {
t.Fatalf("failed to close writer: %v", err)
}
os.Stdout = oldStdout
os.Args = originalArgs
// Read and discard output
var buf bytes.Buffer
if _, err := buf.ReadFrom(r); err != nil {
t.Fatalf("failed to read output: %v", err)
}
AssertError(t, err, tt.wantError, tt.name)
})
}
}
func TestExecuteWithRealCommands(t *testing.T) {
// Test that Execute properly adds all commands
client := fail2ban.NewMockClient()
config := Config{
LogDir: "/tmp",
FilterDir: "/tmp",
Format: "plain",
}
// Create a new root command to test command registration
originalRootCmd := rootCmd
rootCmd = &cobra.Command{
Use: "f2b",
Short: "Fail2Ban CLI helper",
Long: "Fail2Ban CLI tool implemented in Go using Cobra.",
}
// Capture stdout
oldStdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("failed to create pipe: %v", err)
}
os.Stdout = w
originalArgs := os.Args
os.Args = []string{"f2b", "help"}
err = Execute(client, config)
// Restore
if err := w.Close(); err != nil {
t.Fatalf("failed to close writer: %v", err)
}
os.Stdout = oldStdout
os.Args = originalArgs
rootCmd = originalRootCmd
// Read output
var buf bytes.Buffer
if _, err := buf.ReadFrom(r); err != nil {
t.Fatalf("failed to read output: %v", err)
}
output := buf.String()
AssertError(t, err, false, "root help command")
// Check that help output contains expected commands
expectedCommands := []string{
"list-jails",
"status",
"banned",
"ban",
"unban",
"test",
"logs",
"logs-watch",
"service",
"version",
"test-filter",
"completion",
}
for _, cmd := range expectedCommands {
if !strings.Contains(output, cmd) {
t.Errorf("expected help output to contain command %q", cmd)
}
}
}
func TestCompletionCmdExecution(t *testing.T) {
tests := []struct {
name string
args []string
wantOutput string
wantError bool
}{
{
name: "bash completion",
args: []string{"bash"},
wantOutput: "__start_f2b",
wantError: false,
},
{
name: "zsh completion",
args: []string{"zsh"},
wantOutput: "#compdef f2b",
wantError: false,
},
{
name: "fish completion",
args: []string{"fish"},
wantOutput: "complete -c f2b",
wantError: false,
},
{
name: "powershell completion",
args: []string{"powershell"},
wantOutput: "Register-ArgumentCompleter",
wantError: false,
},
{
name: "unsupported shell",
args: []string{"unsupported"},
wantError: true, // Cobra returns an error for invalid args due to OnlyValidArgs
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Framework doesn't support completion cmd yet, so keeping manual approach:
// Create a proper root command structure for the test
testRoot := &cobra.Command{
Use: "f2b",
Short: "Test root command",
}
// Add mock client for commands that need it
mockClient := NewMockClient()
testConfig := Config{Format: "plain"}
// Add all the f2b subcommands to create a realistic structure
testRoot.AddCommand(ListJailsCmd(mockClient, &testConfig))
testRoot.AddCommand(StatusCmd(mockClient, &testConfig))
testRoot.AddCommand(BannedCmd(mockClient, &testConfig))
testRoot.AddCommand(BanCmd(mockClient, &testConfig))
testRoot.AddCommand(UnbanCmd(mockClient, &testConfig))
testRoot.AddCommand(TestIPCmd(mockClient, &testConfig))
testRoot.AddCommand(LogsCmd(mockClient, &testConfig))
testRoot.AddCommand(LogsWatchCmd(context.Background(), mockClient, &testConfig))
testRoot.AddCommand(ServiceCmd(&testConfig))
testRoot.AddCommand(VersionCmd(&testConfig))
testRoot.AddCommand(TestFilterCmd(mockClient, &testConfig))
testRoot.AddCommand(completionCmd())
// Execute the completion command via the root
// Capture stdout
var outBuf bytes.Buffer
testRoot.SetOut(&outBuf)
// Capture stderr
var errBuf bytes.Buffer
testRoot.SetErr(&errBuf)
args := append([]string{"completion"}, tt.args...)
testRoot.SetArgs(args)
err := testRoot.Execute()
AssertError(t, err, tt.wantError, tt.name)
output := outBuf.String() + errBuf.String()
if tt.wantOutput != "" && !tt.wantError {
// Check for substring anywhere in the output, ignoring leading/trailing whitespace
if !strings.Contains(output, tt.wantOutput) {
t.Errorf("expected output to contain %q, got %q", tt.wantOutput, strings.TrimSpace(output))
}
}
})
}
}
func TestInitFunctionCoverage(t *testing.T) {
// Test that init function sets up flags correctly
// We can't directly test init() but we can test its effects
// Test that persistent flags are set
if rootCmd.PersistentFlags().Lookup("log-dir") == nil {
t.Errorf("expected log-dir persistent flag to be set")
}
if rootCmd.PersistentFlags().Lookup("filter-dir") == nil {
t.Errorf("expected filter-dir persistent flag to be set")
}
if rootCmd.PersistentFlags().Lookup("format") == nil {
t.Errorf("expected format persistent flag to be set")
}
if rootCmd.PersistentFlags().Lookup("log-file") == nil {
t.Errorf("expected log-file persistent flag to be set")
}
if rootCmd.PersistentFlags().Lookup("log-level") == nil {
t.Errorf("expected log-level persistent flag to be set")
}
}
func TestPersistentPreRun(t *testing.T) {
// Test the PersistentPreRun function
if rootCmd.PersistentPreRun == nil {
t.Errorf("expected PersistentPreRun to be set")
return
}
// Create a temporary log file
tmpFile, err := os.CreateTemp(t.TempDir(), "f2b-test-*.log")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer func() {
if err := os.Remove(tmpFile.Name()); err != nil { // #nosec G703 -- test file, path from CreateTemp
t.Fatalf("failed to remove temp file: %v", err)
}
}()
defer func() {
if err := tmpFile.Close(); err != nil {
t.Fatalf("failed to close temp file: %v", err)
}
}()
// Test with log file flag
cmd := &cobra.Command{}
cmd.Flags().String("log-file", tmpFile.Name(), "test log file")
cmd.Flags().String("log-level", "debug", "test log level")
// Save original logger output
originalOutput := Logger.Out
// Run PersistentPreRun
rootCmd.PersistentPreRun(cmd, []string{})
// Restore original logger output
Logger.SetOutput(originalOutput)
// Test log level parsing
tests := []struct {
name string
logLevel string
}{
{"debug", "debug"},
{"info", "info"},
{"warn", "warn"},
{"error", "error"},
{"invalid", "invalid"},
}
for _, tt := range tests {
t.Run("log_level_"+tt.name, func(_ *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("log-file", "", "")
cmd.Flags().String("log-level", tt.logLevel, "")
// This should not panic
rootCmd.PersistentPreRun(cmd, []string{})
})
}
}
func TestPersistentPreRunWithInvalidLogFile(t *testing.T) {
// Test PersistentPreRun with invalid log file path
cmd := &cobra.Command{}
cmd.Flags().String("log-file", "/invalid/path/to/logfile.log", "invalid log file")
cmd.Flags().String("log-level", "info", "test log level")
// Capture stderr to check for error message
oldStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("failed to create pipe: %v", err)
}
os.Stderr = w
// This should handle the error gracefully
rootCmd.PersistentPreRun(cmd, []string{})
if err := w.Close(); err != nil {
t.Fatalf("failed to close writer: %v", err)
}
os.Stderr = oldStderr
var buf bytes.Buffer
if _, err := buf.ReadFrom(r); err != nil {
t.Fatalf("failed to read output: %v", err)
}
output := buf.String()
// Should contain error message about failed to open log file
if !strings.Contains(output, "Failed to open log file") {
t.Errorf("expected error message about failed to open log file, got: %s", output)
}
}
func TestCompletionCmdLongDescription(t *testing.T) {
cmd := completionCmd()
// Test that the long description contains instructions for all shells
expectedShells := []string{"Bash:", "Zsh:", "Fish:", "PowerShell:"}
for _, shell := range expectedShells {
if !strings.Contains(cmd.Long, shell) {
t.Errorf("expected completion long description to contain %q", shell)
}
}
// Test that it contains example commands
expectedExamples := []string{
"f2b completion bash",
"f2b completion zsh",
"f2b completion fish",
"f2b completion powershell",
}
for _, example := range expectedExamples {
if !strings.Contains(cmd.Long, example) {
t.Errorf("expected completion long description to contain example %q", example)
}
}
}
func TestGlobalConfigVariable(t *testing.T) {
// Test that global cfg variable can be accessed and modified
originalCfg := cfg
defer func() { cfg = originalCfg }()
cfg = Config{
LogDir: "/test/log",
FilterDir: "/test/filter",
Format: "json",
}
if cfg.LogDir != "/test/log" {
t.Errorf("expected LogDir to be '/test/log', got %q", cfg.LogDir)
}
if cfg.FilterDir != "/test/filter" {
t.Errorf("expected FilterDir to be '/test/filter', got %q", cfg.FilterDir)
}
if cfg.Format != "json" {
t.Errorf("expected Format to be 'json', got %q", cfg.Format)
}
}
// TestExecuteIntegration tests the Execute function with different command combinations
func TestExecuteIntegration(t *testing.T) {
tests := []struct {
name string
args []string
config Config
setupEnv func()
cleanup func()
}{
{
name: "execute with environment variables",
args: []string{"f2b", "version"},
config: Config{
LogDir: "/tmp/test",
FilterDir: "/tmp/filters",
Format: "plain",
},
setupEnv: func() {
// Environment variables will be set using t.Setenv in test loop
},
cleanup: func() {
// Cleanup handled automatically by t.Setenv
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Integration test requires manual approach:
// Set up environment variables using t.Setenv for automatic cleanup
if tt.config.LogDir != "" {
t.Setenv("F2B_LOG_DIR", tt.config.LogDir)
}
if tt.config.FilterDir != "" {
t.Setenv("F2B_FILTER_DIR", tt.config.FilterDir)
}
client := fail2ban.NewMockClient()
// Capture output
oldStdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("failed to create pipe: %v", err)
}
os.Stdout = w
originalArgs := os.Args
os.Args = tt.args
err = Execute(client, tt.config)
// Restore
if closeErr := w.Close(); closeErr != nil {
t.Fatalf("failed to close writer: %v", closeErr)
}
os.Stdout = oldStdout
os.Args = originalArgs
// Read output
var buf bytes.Buffer
if _, readErr := buf.ReadFrom(r); readErr != nil {
t.Fatalf("failed to read output: %v", readErr)
}
AssertError(t, err, false, tt.name)
})
}
}
func TestCompletionCmdWithUnsupportedShell(t *testing.T) {
cmd := completionCmd()
// Capture stderr to check for error message
var errBuf bytes.Buffer
cmd.SetErr(&errBuf)
cmd.SetArgs([]string{"invalid-shell"})
err := cmd.Execute()
// Should return error due to Cobra's OnlyValidArgs validation
if err == nil {
t.Errorf("expected error for invalid shell type")
}
// Error should mention invalid argument
if !strings.Contains(err.Error(), "invalid argument") && !strings.Contains(err.Error(), "invalid") {
t.Errorf("expected error message about invalid argument, got: %v", err)
}
}
// Benchmark tests
func BenchmarkParseLogLevelExtended(b *testing.B) {
levels := []string{"debug", "info", "warn", "warning", "error", "fatal", "panic", "invalid", ""}
b.ResetTimer()
for i := 0; i < b.N; i++ {
level := levels[i%len(levels)]
parseLogLevel(level)
}
}
func BenchmarkExecute(b *testing.B) {
client := fail2ban.NewMockClient()
config := Config{
LogDir: "/tmp",
FilterDir: "/tmp",
Format: "plain",
}
// Suppress output
oldStdout := os.Stdout
devNull, err := os.Open(os.DevNull)
if err != nil {
b.Fatalf("failed to open dev null: %v", err)
}
defer func() {
if cerr := devNull.Close(); cerr != nil {
b.Fatalf("failed to close dev null: %v", cerr)
}
}()
os.Stdout = devNull
defer func() {
os.Stdout = oldStdout
}()
originalArgs := os.Args
defer func() {
os.Args = originalArgs
}()
b.ResetTimer()
for i := 0; i < b.N; i++ {
os.Args = []string{"f2b", "version"}
if err := Execute(client, config); err != nil {
b.Fatalf("execute failed: %v", err)
}
}
}