test: 补充单元测试覆盖率 29.9% → 36.6%

新建 18 个测试文件,追加 30 个已有测试文件,覆盖协议解析、
错误分类、CEL 表达式求值、YAML 反序列化、字节编码等纯函数。
This commit is contained in:
ZacharyZcR
2026-06-17 12:51:41 +08:00
parent 6eff1d5ccf
commit 0612255893
48 changed files with 7556 additions and 1 deletions
+119
View File
@@ -1,6 +1,7 @@
package core
import (
"sync/atomic"
"testing"
"time"
)
@@ -152,3 +153,121 @@ func TestAdaptivePool_Wait(t *testing.T) {
t.Logf("Wait 测试通过: %v", duration)
}
// =============================================================================
// maybeReduceTarget 补充覆盖
// =============================================================================
// TestMaybeReduceTarget_NoOpWhenRTTLow rttRatio <= 3.0 时不修改 target
func TestMaybeReduceTarget_NoOpWhenRTTLow(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(100, 100, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
defer pool.Release()
initialTarget := atomic.LoadInt32(&pool.target)
// RTTRatio 样本不足(< 20)返回 1.0,远低于 3.0 阈值
pool.maybeReduceTarget()
afterTarget := atomic.LoadInt32(&pool.target)
if afterTarget != initialTarget {
t.Errorf("rttRatio <= 3.0 时 target 不应改变: %d -> %d", initialTarget, afterTarget)
}
}
// TestMaybeReduceTarget_ReducesWhenRTTHigh rttRatio > 3.0 时压低 target 10%
func TestMaybeReduceTarget_ReducesWhenRTTHigh(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(200, 200, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
defer pool.Release()
// 伪造 RTT:让 fastEMA >> slowEMAratio > 3.0
// 方法:先用大 RTT 建立 fastEMA,再用小 RTT 建立 slowEMA
// 更直接:直接操作 atomic 字段(包内测试可以访问)
for i := 0; i < 25; i++ {
metrics.RecordConnect(10 * time.Millisecond) // 先建 baseline
}
// 现在把 fastEMA 人为拉高(写入一个远大于 slowEMA 的值)
pool.metrics.rttFastNs.Store(int64(400 * time.Millisecond))
pool.metrics.rttSlowNs.Store(int64(10 * time.Millisecond))
initialTarget := atomic.LoadInt32(&pool.target)
pool.maybeReduceTarget()
afterTarget := atomic.LoadInt32(&pool.target)
if afterTarget >= initialTarget {
t.Errorf("rttRatio > 3.0 时 target 应被压低: %d -> %d", initialTarget, afterTarget)
}
// 验证是 ×0.9
expected := int32(float64(initialTarget) * 0.9)
if afterTarget != expected {
t.Errorf("target 应为 %d (×0.9), 实际 %d", expected, afterTarget)
}
}
// TestMaybeReduceTarget_ClampToMinTarget target 压低后不低于 ceiling/5 或 10
func TestMaybeReduceTarget_ClampToMinTarget(t *testing.T) {
metrics := &ScanMetrics{}
// ceiling=20, minTarget = max(20/5, 10) = 10
// target=10, newTarget = int(10*0.9) = 9 → 被 clamp 到 10 → newTarget == target → 不更新
pool, err := NewAdaptivePool(10, 20, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
defer pool.Release()
// 强制设置 target=10(初始值就是 10,但确认一下)
atomic.StoreInt32(&pool.target, 10)
// 伪造 rttRatio > 3.0
for i := 0; i < 25; i++ {
metrics.RecordConnect(10 * time.Millisecond)
}
pool.metrics.rttFastNs.Store(int64(400 * time.Millisecond))
pool.metrics.rttSlowNs.Store(int64(10 * time.Millisecond))
pool.maybeReduceTarget()
afterTarget := atomic.LoadInt32(&pool.target)
// newTarget=9 < minTarget=10 → clamp 到 10 → 10 == target → 不写入
if afterTarget != 10 {
t.Errorf("clamp 后 target 应保持 10, 实际 %d", afterTarget)
}
}
// TestMaybeReduceTarget_LargeCeilingMinTarget ceiling 足够大时 minTarget = ceiling/5
func TestMaybeReduceTarget_LargeCeilingMinTarget(t *testing.T) {
metrics := &ScanMetrics{}
// ceiling=100, minTarget = 100/5 = 20
// target=21 → newTarget = int(21*0.9) = 18 → clamp 到 20
pool, err := NewAdaptivePool(21, 100, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
defer pool.Release()
atomic.StoreInt32(&pool.target, 21)
atomic.StoreInt32(&pool.ceiling, 100)
for i := 0; i < 25; i++ {
metrics.RecordConnect(10 * time.Millisecond)
}
pool.metrics.rttFastNs.Store(int64(400 * time.Millisecond))
pool.metrics.rttSlowNs.Store(int64(10 * time.Millisecond))
pool.maybeReduceTarget()
afterTarget := atomic.LoadInt32(&pool.target)
// newTarget=18 < minTarget=20 → store 20; 20 < 21 → 更新
if afterTarget != 20 {
t.Errorf("应 clamp 到 minTarget=20, 实际 %d", afterTarget)
}
}
+131
View File
@@ -462,3 +462,134 @@ func TestBaseScanStrategy_ValidateConfiguration(t *testing.T) {
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")
}
}
+69
View File
@@ -529,3 +529,72 @@ func TestExtras_ToMap_EmptyStringFiltering(t *testing.T) {
}
})
}
// =============================================================================
// ParseVersionInfo 测试
// =============================================================================
func TestParseVersionInfo(t *testing.T) {
tests := []struct {
name string
versionInfo string
foundItems []string
wantVP string // VendorProduct
wantVer string // Version
wantCPE string
}{
{
name: "只有product-斜线分隔符",
versionInfo: " p/Apache/",
wantVP: "Apache",
},
{
name: "product和version-斜线分隔符",
versionInfo: " p/nginx/ v/1.18.0/",
wantVP: "nginx",
wantVer: "1.18.0",
},
{
name: "pipe分隔符",
versionInfo: " p|OpenSSH| v|8.2p1|",
wantVP: "OpenSSH",
wantVer: "8.2p1",
},
{
name: "含$1占位符替换后解析",
versionInfo: " p/OpenSSH/ v/$1/",
foundItems: []string{"8.2p1"},
wantVP: "OpenSSH",
wantVer: "8.2p1",
},
{
name: "CPE解析",
versionInfo: " cpe:/a:apache:httpd:2.4.41",
wantCPE: "a:apache:httpd:2.4.41",
},
{
name: "空VersionInfo返回全空Extras",
versionInfo: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Match{
VersionInfo: tt.versionInfo,
FoundItems: tt.foundItems,
}
got := m.ParseVersionInfo(nil)
if got.VendorProduct != tt.wantVP {
t.Errorf("VendorProduct = %q, want %q", got.VendorProduct, tt.wantVP)
}
if got.Version != tt.wantVer {
t.Errorf("Version = %q, want %q", got.Version, tt.wantVer)
}
if got.CPE != tt.wantCPE {
t.Errorf("CPE = %q, want %q", got.CPE, tt.wantCPE)
}
})
}
}
+112
View File
@@ -2,6 +2,7 @@ package core
import (
"sync"
"sync/atomic"
"testing"
"time"
)
@@ -128,3 +129,114 @@ func TestScanMetrics_ConcurrentSafety(t *testing.T) {
// 验证 RTTRatio 不 panic
_ = m.RTTRatio()
}
// =============================================================================
// 补充测试:按题目要求的函数名
// =============================================================================
// TestScanMetricsTotal — 各计数器各调一次,Total() 应返回 4
func TestScanMetricsTotal(t *testing.T) {
m := &ScanMetrics{}
m.RecordConnect(time.Millisecond)
m.RecordRefused(time.Millisecond)
m.RecordTimeout()
m.RecordExhausted()
if got := m.Total(); got != 4 {
t.Errorf("Total() = %d, want 4", got)
}
}
// TestScanMetricsSnapshot — 记录数据后 Snapshot() 返回正确快照
func TestScanMetricsSnapshot(t *testing.T) {
m := &ScanMetrics{}
m.RecordConnect(5 * time.Millisecond)
m.RecordConnect(10 * time.Millisecond)
m.RecordRefused(2 * time.Millisecond)
m.RecordTimeout()
m.RecordExhausted()
snap := m.Snapshot()
tests := []struct {
name string
got int64
want int64
}{
{"Connects", snap.Connects, 2},
{"Refused", snap.Refused, 1},
{"Timeouts", snap.Timeouts, 1},
{"Exhausted", snap.Exhausted, 1},
}
for _, tt := range tests {
if tt.got != tt.want {
t.Errorf("Snapshot.%s = %d, want %d", tt.name, tt.got, tt.want)
}
}
if snap.RTTFastNs <= 0 {
t.Errorf("Snapshot.RTTFastNs = %d, want > 0", snap.RTTFastNs)
}
}
// TestScanMetricsRTTRatio — 样本不足返回 1.020+ 个相同 RTT 接近 1.0
func TestScanMetricsRTTRatio(t *testing.T) {
t.Run("样本不足返回1.0", func(t *testing.T) {
m := &ScanMetrics{}
for i := 0; i < 19; i++ {
m.RecordConnect(time.Millisecond)
}
if r := m.RTTRatio(); r != 1.0 {
t.Errorf("样本不足 RTTRatio() = %f, want 1.0", r)
}
})
t.Run("稳定RTT接近1.0", func(t *testing.T) {
m := &ScanMetrics{}
for i := 0; i < 30; i++ {
m.RecordConnect(10 * time.Millisecond)
}
r := m.RTTRatio()
if r < 0.9 || r > 1.1 {
t.Errorf("稳定RTT下 RTTRatio() = %f, want ~1.0", r)
}
})
}
// TestScanMetricsRTTFast — 初始为 0,记录后非零
func TestScanMetricsRTTFast(t *testing.T) {
m := &ScanMetrics{}
if m.RTTFast() != 0 {
t.Errorf("初始 RTTFast() = %v, want 0", m.RTTFast())
}
m.RecordConnect(5 * time.Millisecond)
if m.RTTFast() == 0 {
t.Errorf("记录后 RTTFast() 仍为 0")
}
}
// TestMetricsSnapshotTotal — MetricsSnapshot 各字段求和
func TestMetricsSnapshotTotal(t *testing.T) {
snap := MetricsSnapshot{Connects: 1, Refused: 2, Timeouts: 3, Exhausted: 4}
if got := snap.Total(); got != 10 {
t.Errorf("MetricsSnapshot.Total() = %d, want 10", got)
}
}
// TestUpdateEMA — 直接测 updateEMA 行为
func TestUpdateEMA(t *testing.T) {
t.Run("target为0时直接设为sample", func(t *testing.T) {
var a atomic.Int64
updateEMA(&a, 100, 10)
if got := a.Load(); got != 100 {
t.Errorf("初始为0时 updateEMA 结果 = %d, want 100", got)
}
})
t.Run("target非零时做EMA更新", func(t *testing.T) {
var a atomic.Int64
a.Store(200)
// next = 200 + (100-200)/10 = 200 - 10 = 190
updateEMA(&a, 100, 10)
if got := a.Load(); got != 190 {
t.Errorf("EMA更新结果 = %d, want 190", got)
}
})
}
+123
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"sync"
"testing"
"time"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/plugins"
@@ -433,6 +434,128 @@ func TestSelectStrategy_EmptyHostInfo(t *testing.T) {
}
}
// =============================================================================
// 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{
+73
View File
@@ -862,3 +862,76 @@ func TestConvertToTargetInfos_DeepCopy(t *testing.T) {
}
})
}
// =============================================================================
// mergeHostPorts 测试
// =============================================================================
func TestMergeHostPorts(t *testing.T) {
// 结果顺序不确定(map 遍历),用集合比较
toSet := func(ss []string) map[string]struct{} {
m := make(map[string]struct{}, len(ss))
for _, s := range ss {
m[s] = struct{}{}
}
return m
}
setsEqual := func(a, b map[string]struct{}) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if _, ok := b[k]; !ok {
return false
}
}
return true
}
tests := []struct {
name string
a []string
b []string
want []string
}{
{
name: "两个空切片返回空",
a: []string{},
b: []string{},
want: []string{},
},
{
name: "无重复-并集",
a: []string{"1.1.1.1:80"},
b: []string{"2.2.2.2:443"},
want: []string{"1.1.1.1:80", "2.2.2.2:443"},
},
{
name: "有重复-去重",
a: []string{"1.1.1.1:80", "2.2.2.2:443"},
b: []string{"2.2.2.2:443", "3.3.3.3:22"},
want: []string{"1.1.1.1:80", "2.2.2.2:443", "3.3.3.3:22"},
},
{
name: "a为nil-返回b内容",
a: nil,
b: []string{"1.1.1.1:80", "2.2.2.2:443"},
want: []string{"1.1.1.1:80", "2.2.2.2:443"},
},
{
name: "b为nil-返回a内容",
a: []string{"1.1.1.1:80"},
b: nil,
want: []string{"1.1.1.1:80"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mergeHostPorts(tt.a, tt.b)
if !setsEqual(toSet(got), toSet(tt.want)) {
t.Errorf("mergeHostPorts() = %v, want %v", got, tt.want)
}
})
}
}