// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package cmd import ( "context" "testing" "time" "github.com/stretchr/testify/require" "github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/dbutil" "github.com/tamakyi/TamaBox/internal/security/report" ) // TestMergeKeysByHits 覆盖「按封禁口径合并后再定位」的键生成。 // // 顺序很重要:腾讯精修有配额上限,只处理前 N 个 —— 必须优先给最活跃的来源, // 否则配额会花在只命中一次的长尾地址上,而报告的主要来源反而定位不到。 func TestMergeKeysByHits(t *testing.T) { rows := []*db.HoneypotSourceAgg{ {IP: "2408:8207:1234:5678::1", Hits: 5}, {IP: "2408:8207:1234:5678::2", Hits: 3}, // 同 /64 → 合成 8 {IP: "203.0.113.7", Hits: 6}, {IP: "198.51.100.1", Hits: 1}, } got := mergeKeysByHits(rows) require.Len(t, got, 3) // 8 次(合并后的 /64)> 6 次 > 1 次 require.Equal(t, "2408:8207:1234:5678::/64", got[0]) require.Equal(t, "203.0.113.7", got[1]) require.Equal(t, "198.51.100.1", got[2]) } // TestMergeKeysByHitsStable 覆盖同命中数时的稳定排序。 // // 不稳定的话,每次跑报告被腾讯精修覆盖的那批 IP 会随机漂移, // 两个周期的定位精度就不一致了,对比起来像是数据变了。 func TestMergeKeysByHitsStable(t *testing.T) { rows := []*db.HoneypotSourceAgg{ {IP: "203.0.113.9", Hits: 2}, {IP: "203.0.113.2", Hits: 2}, {IP: "203.0.113.5", Hits: 2}, } first := mergeKeysByHits(rows) for i := 0; i < 20; i++ { require.Equal(t, first, mergeKeysByHits(rows), "同命中数时顺序必须稳定") } } // TestSchedulerDue 覆盖周报/月报的发送判定,这是「不重复发、不永远不发」的核心。 func TestSchedulerDue(t *testing.T) { database, cleanup := dbutil.NewTestDB(t, db.AllTables...) t.Cleanup(func() { if err := cleanup("security_report_logs"); err != nil { t.Fatal(err) } }) s := NewSecurityReportScheduler(database) ctx := context.Background() loc := time.Local // now = 2026-10-26(周一)02:00,上一周是 10-19 ~ 10-25 now := time.Date(2026, 10, 26, 2, 0, 0, 0, loc) period := report.PreviousWeek(now) require.Equal(t, "week:2026-W43", period.Key) // 1) 台账里没有记录 → 该发 require.True(t, s.due(period, now)) // 2) 已成功发送 → 不再发(哪怕重启一百次) store := db.NewSecurityReportStore(database) require.NoError(t, store.Save(ctx, &db.SecurityReportLog{ PeriodKey: period.Key, Kind: period.Kind, WindowStart: period.Start, WindowEnd: period.End, Sent: true, Attempts: 1, })) require.False(t, s.due(period, now)) // 3) 发送失败但未超重试上限 → 继续重试 require.NoError(t, store.Save(ctx, &db.SecurityReportLog{ PeriodKey: period.Key, Kind: period.Kind, WindowStart: period.Start, WindowEnd: period.End, Sent: false, Attempts: 3, Error: "smtp auth failed", })) require.True(t, s.due(period, now)) // 4) 超过重试上限 → 停下来等人工介入,避免刷爆日志与邮箱配额 require.NoError(t, store.Save(ctx, &db.SecurityReportLog{ PeriodKey: period.Key, Kind: period.Kind, WindowStart: period.Start, WindowEnd: period.End, Sent: false, Attempts: 5, Error: "smtp auth failed", })) require.False(t, s.due(period, now)) // 5) 周期还没结束 → 不发(哪怕台账里没有记录) future := report.PreviousWeek(now.AddDate(0, 0, 7)) require.False(t, s.due(future, now), "窗口未结束时不能发不完整的报告") }