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

586 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package core
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/plugins"
)
/*
scanner_test.go - Scanner核心逻辑测试
注意:scanner.go 包含大量副作用(HTTP初始化、信号处理、并发控制)。
本测试文件专注于可测试的纯逻辑和算法正确性:
1. 策略选择逻辑(selectStrategy) - 测试4种扫描模式的优先级
2. 端口解析逻辑(parsePort - 测试端口范围验证(1-65535)
3. 任务计数逻辑验证(countApplicableTasks - 使用mock策略测试
测试发现并修复的Bug:
- Bug #1: strconv.Atoi接受负数端口(如 "-80" 被解析为 -80)✅ 已修复
- Bug #2: strconv.Atoi不验证端口范围(如 "99999" 被解析为 99999,超出65535)✅ 已修复
修复方案:
在scanner.go中添加了 parsePort() 辅助函数,验证端口范围 (1-65535)。
非法端口会被记录到日志并返回0,避免传递给插件系统导致未定义行为。
"这代码需要依赖注入,不是测试。但既然现在无法重构,
我们至少验证策略选择和任务计数的逻辑是对的。
更重要的是,测试发现了两个真实的bug,并且都修复了。"
*/
// =============================================================================
// 核心逻辑测试:策略选择
// =============================================================================
func TestWebResultSerializerPreservesDetectedProtocol(t *testing.T) {
serializer := resultSerializers[plugins.ResultTypeWeb]
details := map[string]interface{}{}
result := &plugins.Result{
Type: plugins.ResultTypeWeb,
Success: true,
Output: "https://192.168.1.1:8443",
}
info := &common.HostInfo{Host: "192.168.1.1", Port: 8443}
serializer.fillDetail(result, info, details)
if details["protocol"] != "https" {
t.Fatalf("protocol = %v, 期望 https", details["protocol"])
}
if details["url"] != "https://192.168.1.1:8443" {
t.Fatalf("url = %v, 期望检测出的URL", details["url"])
}
}
// TestSelectStrategy 测试策略选择逻辑
func TestSelectStrategy(t *testing.T) {
// 保存原始配置
cfg := common.GetGlobalConfig()
state := common.GetGlobalState()
origAliveOnly := cfg.AliveOnly
origMode := cfg.Mode
origLocalMode := cfg.LocalMode
origURLs := state.GetURLs()
defer func() {
cfg.AliveOnly = origAliveOnly
cfg.Mode = origMode
cfg.LocalMode = origLocalMode
state.SetURLs(origURLs)
}()
tests := []struct {
name string
setupConfig func()
expectedType string
info common.HostInfo
}{
{
name: "存活检测模式-AliveOnly优先级最高",
setupConfig: func() {
cfg.AliveOnly = true
cfg.Mode = ""
cfg.LocalMode = false
state.SetURLs(nil)
},
expectedType: "*core.AliveScanStrategy",
info: common.HostInfo{Host: "192.168.1.1"},
},
{
name: "存活检测模式-ScanMode=icmp",
setupConfig: func() {
cfg.AliveOnly = false
cfg.Mode = "icmp"
cfg.LocalMode = false
state.SetURLs(nil)
},
expectedType: "*core.AliveScanStrategy",
info: common.HostInfo{Host: "192.168.1.1"},
},
{
name: "本地模式-LocalMode",
setupConfig: func() {
cfg.AliveOnly = false
cfg.Mode = ""
cfg.LocalMode = true
state.SetURLs(nil)
},
expectedType: "*core.LocalScanStrategy",
info: common.HostInfo{Host: "localhost"},
},
{
name: "Web扫描模式-URLs非空",
setupConfig: func() {
cfg.AliveOnly = false
cfg.Mode = ""
cfg.LocalMode = false
state.SetURLs([]string{"http://example.com"})
},
expectedType: "*core.WebScanStrategy",
info: common.HostInfo{Host: "example.com"},
},
{
name: "服务扫描模式-默认",
setupConfig: func() {
cfg.AliveOnly = false
cfg.Mode = ""
cfg.LocalMode = false
state.SetURLs(nil)
},
expectedType: "*core.ServiceScanStrategy",
info: common.HostInfo{Host: "192.168.1.1", Port: 22},
},
{
name: "优先级测试-AliveOnly覆盖LocalMode",
setupConfig: func() {
cfg.AliveOnly = true
cfg.Mode = ""
cfg.LocalMode = true // 被AliveOnly覆盖
state.SetURLs(nil)
},
expectedType: "*core.AliveScanStrategy",
info: common.HostInfo{Host: "localhost"},
},
{
name: "优先级测试-LocalMode覆盖URLs",
setupConfig: func() {
cfg.AliveOnly = false
cfg.Mode = ""
cfg.LocalMode = true
state.SetURLs([]string{"http://example.com"}) // 被LocalMode覆盖
},
expectedType: "*core.LocalScanStrategy",
info: common.HostInfo{Host: "localhost"},
},
{
name: "优先级测试-URLs覆盖默认服务扫描",
setupConfig: func() {
cfg.AliveOnly = false
cfg.Mode = ""
cfg.LocalMode = false
state.SetURLs([]string{"http://example.com"})
},
expectedType: "*core.WebScanStrategy",
info: common.HostInfo{Host: "192.168.1.1", Port: 80},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 设置配置
tt.setupConfig()
// 执行策略选择
strategy := selectStrategy(cfg, state, tt.info)
// 验证策略类型
strategyType := fmt.Sprintf("%T", strategy)
if strategyType != tt.expectedType {
t.Errorf("selectStrategy() 类型 = %s, 期望 %s", strategyType, tt.expectedType)
}
// 验证策略不为nil
if strategy == nil {
t.Error("selectStrategy() 返回 nil")
}
})
}
}
// TestSelectStrategy_AllModesDisabled 测试所有模式禁用时的默认行为
func TestSelectStrategy_AllModesDisabled(t *testing.T) {
// 保存原始配置
cfg := common.GetGlobalConfig()
state := common.GetGlobalState()
origAliveOnly := cfg.AliveOnly
origMode := cfg.Mode
origLocalMode := cfg.LocalMode
origURLs := state.GetURLs()
defer func() {
cfg.AliveOnly = origAliveOnly
cfg.Mode = origMode
cfg.LocalMode = origLocalMode
state.SetURLs(origURLs)
}()
// 设置所有模式为禁用状态
cfg.AliveOnly = false
cfg.Mode = ""
cfg.LocalMode = false
state.SetURLs(nil)
info := common.HostInfo{Host: "192.168.1.1"}
strategy := selectStrategy(cfg, state, info)
// 应该返回默认的ServiceScanStrategy
expectedType := "*core.ServiceScanStrategy"
strategyType := fmt.Sprintf("%T", strategy)
if strategyType != expectedType {
t.Errorf("默认策略类型 = %s, 期望 %s", strategyType, expectedType)
}
}
// =============================================================================
// =============================================================================
// 任务计数逻辑测试(需要mock策略)
// =============================================================================
// mockStrategy 用于测试的mock策略
type mockStrategy struct {
plugins []string
isCustomMode bool
applicablePlugins map[string]bool // pluginName -> isApplicable
}
func (m *mockStrategy) Execute(_ context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
}
func (m *mockStrategy) GetPlugins() ([]string, bool) {
return m.plugins, m.isCustomMode
}
func (m *mockStrategy) IsPluginApplicableByName(pluginName string, targetHost string, targetPort int, isCustomMode bool) bool {
if m.applicablePlugins == nil {
return true // 默认都适用
}
return m.applicablePlugins[pluginName]
}
// TestCountApplicableTasks 测试任务计数逻辑
func TestCountApplicableTasks(t *testing.T) {
tests := []struct {
name string
targets []common.HostInfo
strategy *mockStrategy
setupPlugins func()
expected int
}{
{
name: "空目标列表",
targets: []common.HostInfo{},
strategy: &mockStrategy{
plugins: []string{"ssh", "mysql"},
isCustomMode: false,
},
setupPlugins: func() {},
expected: 0,
},
{
name: "单目标单插件",
targets: []common.HostInfo{
{Host: "192.168.1.1", Port: 22},
},
strategy: &mockStrategy{
plugins: []string{"ssh"},
isCustomMode: false,
},
setupPlugins: func() {},
expected: 1, // 取决于插件是否存在
},
{
name: "单目标多插件",
targets: []common.HostInfo{
{Host: "192.168.1.1", Port: 22},
},
strategy: &mockStrategy{
plugins: []string{"ssh", "mysql", "redis"},
isCustomMode: false,
},
setupPlugins: func() {},
expected: 3, // 假设所有插件都存在且适用
},
{
name: "多目标单插件",
targets: []common.HostInfo{
{Host: "192.168.1.1", Port: 22},
{Host: "192.168.1.2", Port: 22},
{Host: "192.168.1.3", Port: 22},
},
strategy: &mockStrategy{
plugins: []string{"ssh"},
isCustomMode: false,
},
setupPlugins: func() {},
expected: 3,
},
{
name: "多目标多插件",
targets: []common.HostInfo{
{Host: "192.168.1.1", Port: 22},
{Host: "192.168.1.2", Port: 80},
},
strategy: &mockStrategy{
plugins: []string{"ssh", "http"},
isCustomMode: false,
},
setupPlugins: func() {},
expected: 4, // 2 targets * 2 plugins
},
{
name: "部分插件不适用",
targets: []common.HostInfo{
{Host: "192.168.1.1", Port: 22},
{Host: "192.168.1.2", Port: 80},
},
strategy: &mockStrategy{
plugins: []string{"ssh", "http", "mysql"},
isCustomMode: false,
applicablePlugins: map[string]bool{
"ssh": true,
"http": true,
"mysql": false, // mysql不适用
},
},
setupPlugins: func() {},
expected: 4, // 2 targets * 2 applicable plugins
},
{
name: "空端口-端口为0",
targets: []common.HostInfo{
{Host: "192.168.1.1", Port: 0},
},
strategy: &mockStrategy{
plugins: []string{"ssh"},
isCustomMode: false,
},
setupPlugins: func() {},
expected: 1,
},
{
name: "非法端口-解析为0",
targets: []common.HostInfo{
{Host: "192.168.1.1", Port: 0},
},
strategy: &mockStrategy{
plugins: []string{"ssh"},
isCustomMode: false,
},
setupPlugins: func() {},
expected: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setupPlugins()
// 注意:实际的countApplicableTasks依赖plugins.Exists()
// 这里我们只能测试逻辑结构,无法验证实际插件系统
// 这是"上帝函数"的典型问题:无法mock依赖
// 提取纯逻辑测试
count := 0
for _, target := range tt.targets {
targetPort := target.Port
pluginsToRun, isCustomMode := tt.strategy.GetPlugins()
for _, pluginName := range pluginsToRun {
// 跳过plugins.Exists检查(无法mock
if tt.strategy.IsPluginApplicableByName(pluginName, target.Host, targetPort, isCustomMode) {
count++
}
}
}
if count != tt.expected {
t.Errorf("任务计数 = %d, 期望 %d", count, tt.expected)
}
})
}
}
// =============================================================================
// 边界情况测试
// =============================================================================
// TestSelectStrategy_EmptyHostInfo 测试空HostInfo的策略选择
func TestSelectStrategy_EmptyHostInfo(t *testing.T) {
// 保存原始配置
cfg := common.GetGlobalConfig()
state := common.GetGlobalState()
origAliveOnly := cfg.AliveOnly
origMode := cfg.Mode
origLocalMode := cfg.LocalMode
origURLs := state.GetURLs()
defer func() {
cfg.AliveOnly = origAliveOnly
cfg.Mode = origMode
cfg.LocalMode = origLocalMode
state.SetURLs(origURLs)
}()
cfg.AliveOnly = false
cfg.Mode = ""
cfg.LocalMode = false
state.SetURLs(nil)
emptyInfo := common.HostInfo{}
strategy := selectStrategy(cfg, state, emptyInfo)
if strategy == nil {
t.Error("selectStrategy() 不应对空HostInfo返回nil")
}
// 应该返回默认策略
expectedType := "*core.ServiceScanStrategy"
strategyType := fmt.Sprintf("%T", strategy)
if strategyType != expectedType {
t.Errorf("空HostInfo策略类型 = %s, 期望 %s", strategyType, expectedType)
}
}
// =============================================================================
// buildScanReport 测试
// =============================================================================
// TestBuildScanReport 验证 buildScanReport 字段映射正确
func TestBuildScanReport(t *testing.T) {
state := common.NewState()
// 填充各计数器
state.SetEnd(10)
state.SetNum(7)
state.IncrementTCPSuccessPacketCount() // +1 total, +1 tcp, +1 tcpSuccess
state.IncrementTCPSuccessPacketCount() // +1 total, +1 tcp, +1 tcpSuccess
state.IncrementTCPFailedPacketCount() // +1 total, +1 tcp, +1 tcpFailed
state.IncrementUDPPacketCount() // +1 total, +1 udp
state.IncrementHTTPPacketCount() // +1 total, +1 http
state.IncrementResourceExhaustedCount()
start := time.Now().Add(-time.Second) // 模拟 1 秒前开始
report := buildScanReport(state, start)
if report.TasksTotal != 10 {
t.Errorf("TasksTotal = %d, 期望 10", report.TasksTotal)
}
if report.TasksCompleted != 7 {
t.Errorf("TasksCompleted = %d, 期望 7", report.TasksCompleted)
}
if report.Packets != 5 {
t.Errorf("Packets = %d, 期望 5", report.Packets)
}
if report.TCPPackets != 3 {
t.Errorf("TCPPackets = %d, 期望 3", report.TCPPackets)
}
if report.TCPSuccessPackets != 2 {
t.Errorf("TCPSuccessPackets = %d, 期望 2", report.TCPSuccessPackets)
}
if report.TCPFailedPackets != 1 {
t.Errorf("TCPFailedPackets = %d, 期望 1", report.TCPFailedPackets)
}
if report.UDPPackets != 1 {
t.Errorf("UDPPackets = %d, 期望 1", report.UDPPackets)
}
if report.HTTPPackets != 1 {
t.Errorf("HTTPPackets = %d, 期望 1", report.HTTPPackets)
}
if report.ResourceExhausted != 1 {
t.Errorf("ResourceExhausted = %d, 期望 1", report.ResourceExhausted)
}
if report.Duration < time.Millisecond {
t.Errorf("Duration = %v, 期望 >= 1ms", report.Duration)
}
}
// TestBuildScanReport_ZeroState 验证空 State 返回零值报告
func TestBuildScanReport_ZeroState(t *testing.T) {
state := common.NewState()
start := time.Now()
report := buildScanReport(state, start)
if report.TasksTotal != 0 || report.TasksCompleted != 0 || report.Packets != 0 {
t.Errorf("空 State 期望全零报告,实际 %+v", report)
}
if report.Duration < 0 {
t.Errorf("Duration 不能为负: %v", report.Duration)
}
}
// =============================================================================
// determineScanMode IsLocalMode 分支测试
// =============================================================================
// TestDetermineScanMode_IsLocalModeCallback 覆盖 IsLocalMode 回调分支
func TestDetermineScanMode_IsLocalModeCallback(t *testing.T) {
// 保存原始值
origIsLocalMode := common.IsLocalMode
defer func() { common.IsLocalMode = origIsLocalMode }()
// 注册回调:mode == "localtest" 时认为是本地模式
common.IsLocalMode = func(mode string) bool {
return mode == "localtest"
}
cfg := &common.Config{
AliveOnly: false,
Mode: "localtest",
LocalMode: false,
}
state := common.NewState()
mode := determineScanMode(cfg, state)
if mode != ScanModeLocal {
t.Errorf("determineScanMode() = %v, 期望 ScanModeLocal", mode)
}
// 回调命中后应同时设置 LocalMode 和 LocalPlugin
if !cfg.LocalMode {
t.Error("IsLocalMode 命中后应设置 cfg.LocalMode = true")
}
if cfg.LocalPlugin != "localtest" {
t.Errorf("LocalPlugin = %q, 期望 \"localtest\"", cfg.LocalPlugin)
}
}
// TestDetermineScanMode_IsLocalModeCallbackNoMatch 回调不命中时不影响模式
func TestDetermineScanMode_IsLocalModeCallbackNoMatch(t *testing.T) {
origIsLocalMode := common.IsLocalMode
defer func() { common.IsLocalMode = origIsLocalMode }()
common.IsLocalMode = func(mode string) bool { return false }
cfg := &common.Config{
AliveOnly: false,
Mode: "something",
LocalMode: false,
}
state := common.NewState()
mode := determineScanMode(cfg, state)
if mode != ScanModeService {
t.Errorf("回调不命中时期望 ScanModeService, 实际 %v", mode)
}
}
// TestCountApplicableTasks_EmptyPlugins 测试空插件列表
func TestCountApplicableTasks_EmptyPlugins(t *testing.T) {
targets := []common.HostInfo{
{Host: "192.168.1.1", Port: 22},
}
strategy := &mockStrategy{
plugins: []string{},
isCustomMode: false,
}
count := 0
for _, target := range targets {
targetPort := target.Port
pluginsToRun, isCustomMode := strategy.GetPlugins()
for _, pluginName := range pluginsToRun {
if strategy.IsPluginApplicableByName(pluginName, target.Host, targetPort, isCustomMode) {
count++
}
}
}
if count != 0 {
t.Errorf("空插件列表应返回0任务, 实际 %d", count)
}
}