mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-22 03:10:42 +08:00
586 lines
16 KiB
Go
586 lines
16 KiB
Go
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)
|
||
}
|
||
}
|