Files
fscan/core/base_scan_strategy_test.go
T
ZacharyZcR 0612255893 test: 补充单元测试覆盖率 29.9% → 36.6%
新建 18 个测试文件,追加 30 个已有测试文件,覆盖协议解析、
错误分类、CEL 表达式求值、YAML 反序列化、字节编码等纯函数。
2026-06-17 12:51:41 +08:00

596 lines
18 KiB
Go

package core
import (
"testing"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/plugins"
)
// =============================================================================
// 插件列表解析测试
// =============================================================================
/*
插件列表解析 - parsePluginList 函数测试
测试价值:用户输入解析是扫描器的入口,解析错误会导致用户指定的插件无法执行
"字符串解析看起来简单,但边界情况会咬你一口。空格、空字符串、
逗号分隔符——这些是真实的bug来源。必须测试。"
*/
// TestParsePluginList_BasicCases 测试基本的插件列表解析
func TestParsePluginList_BasicCases(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "单个插件",
input: "ssh",
expected: []string{"ssh"},
},
{
name: "两个插件-逗号分隔",
input: "ssh,redis",
expected: []string{"ssh", "redis"},
},
{
name: "多个插件-逗号分隔",
input: "ssh,redis,mysql,mssql",
expected: []string{"ssh", "redis", "mysql", "mssql"},
},
{
name: "空字符串",
input: "",
expected: []string{},
},
{
name: "单个逗号",
input: ",",
expected: []string{},
},
{
name: "多个逗号",
input: ",,,",
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parsePluginList(tt.input)
if !slicesEqual(result, tt.expected) {
t.Errorf("parsePluginList(%q) = %v, want %v",
tt.input, result, tt.expected)
}
})
}
}
// TestParsePluginList_Whitespace 测试空格处理
func TestParsePluginList_Whitespace(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "插件名前后有空格",
input: " ssh ",
expected: []string{"ssh"},
},
{
name: "逗号前后有空格",
input: "ssh , redis",
expected: []string{"ssh", "redis"},
},
{
name: "多个空格",
input: " ssh , redis ",
expected: []string{"ssh", "redis"},
},
{
name: "Tab字符",
input: "ssh\t,\tredis",
expected: []string{"ssh", "redis"},
},
{
name: "混合空白字符",
input: " \tssh\t , \tredis \t",
expected: []string{"ssh", "redis"},
},
{
name: "只有空格",
input: " ",
expected: []string{},
},
{
name: "空格和逗号混合",
input: " , , , ",
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parsePluginList(tt.input)
if !slicesEqual(result, tt.expected) {
t.Errorf("parsePluginList(%q) = %v, want %v",
tt.input, result, tt.expected)
}
})
}
}
// TestParsePluginList_EdgeCases 测试边界情况
func TestParsePluginList_EdgeCases(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "连续逗号",
input: "ssh,,redis",
expected: []string{"ssh", "redis"},
},
{
name: "开头有逗号",
input: ",ssh,redis",
expected: []string{"ssh", "redis"},
},
{
name: "结尾有逗号",
input: "ssh,redis,",
expected: []string{"ssh", "redis"},
},
{
name: "开头结尾都有逗号",
input: ",ssh,redis,",
expected: []string{"ssh", "redis"},
},
{
name: "空元素混合",
input: "ssh, ,redis, , ,mysql",
expected: []string{"ssh", "redis", "mysql"},
},
{
name: "单字符插件名",
input: "a,b,c",
expected: []string{"a", "b", "c"},
},
{
name: "长插件名",
input: "verylongpluginname1,verylongpluginname2",
expected: []string{"verylongpluginname1", "verylongpluginname2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parsePluginList(tt.input)
if !slicesEqual(result, tt.expected) {
t.Errorf("parsePluginList(%q) = %v, want %v",
tt.input, result, tt.expected)
}
})
}
}
// TestParsePluginList_ProductionScenarios 测试生产环境真实场景
func TestParsePluginList_ProductionScenarios(t *testing.T) {
t.Run("用户复制粘贴带空格", func(t *testing.T) {
// 用户从文档复制 "ssh, redis, mysql" 粘贴到命令行
input := "ssh, redis, mysql"
expected := []string{"ssh", "redis", "mysql"}
result := parsePluginList(input)
if !slicesEqual(result, expected) {
t.Errorf("应该正确处理用户复制粘贴的空格")
}
})
t.Run("用户手误多打逗号", func(t *testing.T) {
// 用户打错了:"ssh,,redis"
input := "ssh,,redis"
expected := []string{"ssh", "redis"}
result := parsePluginList(input)
if !slicesEqual(result, expected) {
t.Errorf("应该容错处理连续逗号")
}
})
t.Run("常见的all模式", func(t *testing.T) {
// 虽然 "all" 在上层处理,但解析器也要能处理
input := "all"
expected := []string{"all"}
result := parsePluginList(input)
if !slicesEqual(result, expected) {
t.Errorf("应该正确解析 'all' 关键字")
}
})
t.Run("混合大小写插件名", func(t *testing.T) {
// Go插件名通常小写,但用户可能输入大写
input := "SSH,Redis,MySQL"
expected := []string{"SSH", "Redis", "MySQL"}
result := parsePluginList(input)
// 注意:当前实现不做大小写转换,保留原始输入
if !slicesEqual(result, expected) {
t.Errorf("应该保留原始大小写(交给上层验证)")
}
})
}
// TestParsePluginList_ReturnValue 测试返回值特性
func TestParsePluginList_ReturnValue(t *testing.T) {
t.Run("返回空切片而非nil", func(t *testing.T) {
result := parsePluginList("")
if result == nil {
t.Error("空输入应该返回空切片,而不是nil")
}
if len(result) != 0 {
t.Errorf("空输入应该返回长度为0的切片,got length %d", len(result))
}
})
t.Run("返回新切片-不共享内存", func(t *testing.T) {
input := "ssh,redis"
result1 := parsePluginList(input)
result2 := parsePluginList(input)
// 修改result1不应该影响result2
if len(result1) > 0 {
result1[0] = "modified"
if result2[0] == "modified" {
t.Error("每次调用应该返回新的切片,不共享内存")
}
}
})
}
// slicesEqual 比较两个字符串切片是否相等
func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestOrderWebPlugins(t *testing.T) {
plugins := []string{"ssh", "webpoc", "redis", "webtitle", "mysql"}
orderWebPlugins(plugins)
expected := []string{"webtitle", "ssh", "redis", "mysql", "webpoc"}
if !slicesEqual(plugins, expected) {
t.Fatalf("orderWebPlugins = %#v, want %#v", plugins, expected)
}
}
func TestBaseScanStrategyPluginSelectionAndApplicability(t *testing.T) {
registerTestPlugins(t)
plugins.RegisterWithOptions("core_test_local", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false)
plugins.RegisterWithOptions("core_test_udp", func() plugins.Plugin { return nil }, []int{161}, []string{plugins.PluginTypeUDP}, true)
clearServiceCache()
cfg := common.NewConfig()
cfg.Mode = "ssh, missing_plugin, webtitle"
strategy := NewBaseScanStrategy("service", FilterService)
got, custom := strategy.GetPlugins(cfg)
if !custom {
t.Fatal("explicit mode should be marked as custom")
}
if !slicesEqual(got, []string{"ssh", "webtitle"}) {
t.Fatalf("custom plugins = %#v, want ssh/webtitle", got)
}
cfg.Mode = "all"
servicePlugins, custom := strategy.GetPlugins(cfg)
if custom {
t.Fatal("all mode should not be custom")
}
if !containsString(servicePlugins, "ssh") || containsString(servicePlugins, "core_test_local") || containsString(servicePlugins, "core_test_udp") {
t.Fatalf("service filtered plugins = %#v", servicePlugins)
}
if !strategy.pluginExists("ssh") || strategy.pluginExists("missing_plugin") {
t.Fatal("pluginExists returned wrong result")
}
if !strategy.isPluginApplicableToPort("ssh", 22) || strategy.isPluginApplicableToPort("ssh", 23) {
t.Fatal("port applicability for ssh is wrong")
}
CacheServiceInfo("10.0.0.9", 22222, &ServiceInfo{Name: "ssh"})
if !strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.9", 22222) {
t.Fatal("service cache should allow ssh on a non-standard port")
}
if !strategy.IsPluginApplicableByName("ssh", "10.0.0.9", 1, true, cfg) {
t.Fatal("custom mode should respect explicitly selected plugin")
}
if strategy.IsPluginApplicableByName("missing_plugin", "10.0.0.9", 22, true, cfg) {
t.Fatal("missing plugin should never be applicable")
}
}
func TestBaseScanStrategyFilterTypes(t *testing.T) {
plugins.RegisterWithOptions("core_test_local_filter", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false)
plugins.RegisterWithOptions("core_test_web_filter", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeWeb}, true)
plugins.RegisterWithOptions("core_test_udp_filter", func() plugins.Plugin { return nil }, []int{53}, []string{plugins.PluginTypeUDP}, true)
cfg := common.NewConfig()
localStrategy := NewBaseScanStrategy("local", FilterLocal)
if localStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) {
t.Fatal("local plugin should require explicit -local selection")
}
cfg.LocalPlugin = "core_test_local_filter"
if !localStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) {
t.Fatal("explicit local plugin should pass local filter")
}
serviceStrategy := NewBaseScanStrategy("service", FilterService)
if !serviceStrategy.isPluginPassesFilterType("ssh", false, cfg) {
t.Fatal("service plugin should pass service filter")
}
if serviceStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) ||
serviceStrategy.isPluginPassesFilterType("core_test_udp_filter", false, cfg) {
t.Fatal("service filter should reject local and UDP plugins")
}
webStrategy := NewBaseScanStrategy("web", FilterWeb)
if !webStrategy.isPluginPassesFilterType("core_test_web_filter", false, cfg) ||
webStrategy.isPluginPassesFilterType("ssh", false, cfg) {
t.Fatal("web filter should only allow web plugins")
}
if webPluginOrder("webtitle") != 0 || webPluginOrder("webpoc") != 2 || webPluginOrder("other") != 1 {
t.Fatal("web plugin order changed")
}
}
func TestFormatPluginList(t *testing.T) {
if got := formatPluginList([]string{"a", "b", "c"}); got != "a, b, c" {
t.Fatalf("short plugin list = %q", got)
}
if got := formatPluginList([]string{"a", "b", "c", "d", "e", "f"}); got == "" || got == "a, b, c, d, e, f" {
t.Fatalf("long plugin list should be summarized, got %q", got)
}
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
// TestNewBaseScanStrategy 测试构造函数
func TestNewBaseScanStrategy(t *testing.T) {
tests := []struct {
name string
strategyName string
filterType PluginFilterType
}{
{
name: "FilterNone",
strategyName: "无过滤",
filterType: FilterNone,
},
{
name: "FilterLocal",
strategyName: "本地扫描",
filterType: FilterLocal,
},
{
name: "FilterService",
strategyName: "服务扫描",
filterType: FilterService,
},
{
name: "FilterWeb",
strategyName: "Web扫描",
filterType: FilterWeb,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
strategy := NewBaseScanStrategy(tt.strategyName, tt.filterType)
if strategy == nil {
t.Fatal("NewBaseScanStrategy 返回 nil")
}
if strategy.strategyName != tt.strategyName {
t.Errorf("strategyName: 期望 %q, 实际 %q", tt.strategyName, strategy.strategyName)
}
if strategy.filterType != tt.filterType {
t.Errorf("filterType: 期望 %d, 实际 %d", tt.filterType, strategy.filterType)
}
})
}
}
// TestPluginFilterTypeConstants 测试过滤器类型常量
func TestPluginFilterTypeConstants(t *testing.T) {
// 验证常量值的唯一性和连续性
filterTypes := []PluginFilterType{
FilterNone,
FilterLocal,
FilterService,
FilterWeb,
}
// 检查值是否唯一
seen := make(map[PluginFilterType]bool)
for _, ft := range filterTypes {
if seen[ft] {
t.Errorf("PluginFilterType 值重复: %d", ft)
}
seen[ft] = true
}
// 验证预期值
expectedValues := map[PluginFilterType]int{
FilterNone: 0,
FilterLocal: 1,
FilterService: 2,
FilterWeb: 3,
}
for ft, expectedVal := range expectedValues {
if int(ft) != expectedVal {
t.Errorf("PluginFilterType %d: 期望值 %d, 实际值 %d", ft, expectedVal, int(ft))
}
}
}
// TestBaseScanStrategy_ValidateConfiguration 测试配置验证
func TestBaseScanStrategy_ValidateConfiguration(t *testing.T) {
strategy := NewBaseScanStrategy("测试", FilterNone)
err := strategy.ValidateConfiguration()
if err != nil {
t.Errorf("ValidateConfiguration 应返回 nil, 实际: %v", err)
}
}
// =============================================================================
// IsPluginApplicableByName 补充覆盖
// =============================================================================
// TestIsPluginApplicableByName_FullModeWebPlugin 测试 -full 模式下 web 插件对任意端口生效
func TestIsPluginApplicableByName_FullModeWebPlugin(t *testing.T) {
registerTestPlugins(t)
clearServiceCache()
cfg := common.NewConfig()
cfg.POC.Full = true
strategy := NewBaseScanStrategy("service", FilterService)
// webtitle 是 web 插件;-full 模式下不检查 IsMarkedWebService,直接走 passesFilterType
// FilterService 不允许 local/udp,但允许 web 插件
got := strategy.IsPluginApplicableByName("webtitle", "10.0.0.1", 12345, false, cfg)
if !got {
t.Error("full 模式下 web 插件应对任意端口返回 true")
}
}
// TestIsPluginApplicableByName_FullModeNonWebPlugin 确认 -full 不影响非 web 插件的端口匹配
func TestIsPluginApplicableByName_FullModeNonWebPlugin(t *testing.T) {
registerTestPlugins(t)
clearServiceCache()
cfg := common.NewConfig()
cfg.POC.Full = true
strategy := NewBaseScanStrategy("service", FilterService)
// ssh 不是 web 插件,-full 无特殊逻辑,走普通端口匹配
// ssh 默认端口 22;用 99999 端口应该不匹配
got := strategy.IsPluginApplicableByName("ssh", "10.0.0.1", 99999, false, cfg)
if got {
t.Error("-full 模式对非 web 插件不应绕过端口匹配")
}
}
// =============================================================================
// isPluginApplicableToPort 补充覆盖
// =============================================================================
// TestIsPluginApplicableToPort_WebPlugin web 插件忽略端口直接返回 true
func TestIsPluginApplicableToPort_WebPlugin(t *testing.T) {
registerTestPlugins(t)
strategy := NewBaseScanStrategy("service", FilterService)
// webtitle 是 web 插件,任何端口都应返回 true
if !strategy.isPluginApplicableToPort("webtitle", 8080) {
t.Error("web 插件在任意端口应返回 true")
}
if !strategy.isPluginApplicableToPort("webtitle", 0) {
t.Error("web 插件在端口 0 也应返回 true")
}
}
// TestIsPluginApplicableToPort_NonWebPlugin 非 web 插件走端口匹配逻辑
func TestIsPluginApplicableToPort_NonWebPlugin(t *testing.T) {
registerTestPlugins(t)
clearServiceCache()
strategy := NewBaseScanStrategy("service", FilterService)
// ssh 端口 22 匹配
if !strategy.isPluginApplicableToPort("ssh", 22) {
t.Error("ssh 应匹配端口 22")
}
// ssh 端口 9999 不匹配(无服务缓存)
if strategy.isPluginApplicableToPort("ssh", 9999) {
t.Error("ssh 不应匹配端口 9999")
}
}
// =============================================================================
// isPluginPassesFilterType 补充覆盖
// =============================================================================
// TestIsPluginPassesFilterType_CustomMode isCustomMode=true 应直接跳过过滤返回 true(非 UDP)
func TestIsPluginPassesFilterType_CustomMode(t *testing.T) {
registerTestPlugins(t)
cfg := common.NewConfig()
// FilterLocal 策略下 custom mode 也应通过
localStrategy := NewBaseScanStrategy("local", FilterLocal)
if !localStrategy.isPluginPassesFilterType("ssh", true, cfg) {
t.Error("custom mode 下非 UDP 插件应直接返回 true")
}
// FilterService 策略下 custom mode 也应通过
serviceStrategy := NewBaseScanStrategy("service", FilterService)
if !serviceStrategy.isPluginPassesFilterType("ssh", true, cfg) {
t.Error("custom mode 下 service 策略应直接返回 true")
}
}
// TestIsPluginPassesFilterType_FilterNoneNonLocal FilterNone + 普通 TCP 插件 → true
func TestIsPluginPassesFilterType_FilterNoneNonLocal(t *testing.T) {
registerTestPlugins(t)
cfg := common.NewConfig()
noneStrategy := NewBaseScanStrategy("none", FilterNone)
// ssh 不是 local 插件,FilterNone 应直接返回 true
if !noneStrategy.isPluginPassesFilterType("ssh", false, cfg) {
t.Error("FilterNone + 非 local 插件应返回 true")
}
if !noneStrategy.isPluginPassesFilterType("redis", false, cfg) {
t.Error("FilterNone + 非 local 插件 redis 应返回 true")
}
}
// TestIsPluginPassesFilterType_FilterNoneLocalPlugin FilterNone + local 插件:需要 -local 显式指定
func TestIsPluginPassesFilterType_FilterNoneLocalPlugin(t *testing.T) {
plugins.RegisterWithOptions("core_test_local_none", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false)
cfg := common.NewConfig()
noneStrategy := NewBaseScanStrategy("none", FilterNone)
// 未指定 LocalPlugin,应返回 false
if noneStrategy.isPluginPassesFilterType("core_test_local_none", false, cfg) {
t.Error("FilterNone + local 插件未显式指定时应返回 false")
}
// 指定后应返回 true
cfg.LocalPlugin = "core_test_local_none"
if !noneStrategy.isPluginPassesFilterType("core_test_local_none", false, cfg) {
t.Error("FilterNone + local 插件显式指定后应返回 true")
}
}