// 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 report import ( "testing" "time" "github.com/stretchr/testify/require" "github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/security/geoip" ) // TestBuildMergesIPv6By64 覆盖「同一台机器在 /64 内轮换地址」的合并语义。 // // 这是报告最容易出错的地方:按精确地址统计会把一台机器算成 8 个来源, // 于是「独立来源数」虚高、地理聚合里同一个城市出现多次。 func TestBuildMergesIPv6By64(t *testing.T) { loc := time.Local start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} sources := []*db.HoneypotSourceAgg{ {IP: "2001:db8:1:2::1", Hits: 10, Banned: 10, Creds: 2}, {IP: "2001:db8:1:2::2", Hits: 5, Banned: 5, Creds: 1}, {IP: "2001:db8:1:3::9", Hits: 1}, // 不同 /64,不能合并 {IP: "203.0.113.7", Hits: 3, Banned: 3, Creds: 3}, } geo := map[string]geoip.Geo{ "2001:db8:1:2::/64": {Country: "中国", CountryCode: "cn", Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, HasCoords: true}, "2001:db8:1:3::/64": {Country: "中国", CountryCode: "CN", Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4, HasCoords: true}, "203.0.113.7": {Country: "美国", CountryCode: "US", Region: "California", City: "Fremont", Lat: 37.5, Lon: -121.9, HasCoords: true}, } sum := Build(Input{ Period: period, Sources: sources, Geo: geo, Now: start.AddDate(0, 0, 7), }) require.Equal(t, int64(19), sum.TotalHits) // 四条精确地址压成三个来源(两个 /64 + 一个 IPv4): // 若不合并会是 4,「同一个 C 段算成两台机器」的错觉就来自这里。 require.Equal(t, 3, sum.UniqueSources) // 合并后的 /64 命中数应为 15,且排第一;展示用的 IP 取该 /64 里最活跃的 // 那一台精确地址(::1 命中 10 次 > ::2 的 5 次)—— 报告里要能直接复制 // 这个地址去查日志,所以不展示折后的前缀。 require.Equal(t, "2001:db8:1:2::1", sum.Sources[0].IP) require.Equal(t, int64(15), sum.Sources[0].Hits) require.Equal(t, int64(3), sum.Sources[0].Creds) // 两个 /64 都在北京,城市聚合应当合成一个 16 次的气泡。 require.Len(t, sum.Cities, 2) require.Equal(t, "北京", sum.Cities[0].City) require.Equal(t, int64(16), sum.Cities[0].Hits) // 国家码必须统一成大写(ip-api 与腾讯的大小写不一致,不统一会出现 // 同一个国家被拆成 CN / cn 两行)。 require.Len(t, sum.Countries, 2) require.Equal(t, "CN", sum.Countries[0].Code) require.Equal(t, "US", sum.Countries[1].Code) require.Equal(t, int64(16), sum.Countries[0].Hits) } // TestBuildUnlocated 覆盖「查不到位置」的显式统计。 // // 覆盖率必须是报告里的显式指标:一张 60% 覆盖的地图和 100% 的看起来一样。 func TestBuildUnlocated(t *testing.T) { loc := time.Local start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} sum := Build(Input{ Period: period, Sources: []*db.HoneypotSourceAgg{ {IP: "198.51.100.1", Hits: 7}, {IP: "203.0.113.9", Hits: 3}, }, Geo: map[string]geoip.Geo{ "203.0.113.9": {Country: "日本", CountryCode: "JP", Lat: 35.6, Lon: 139.7, HasCoords: true}, }, Now: start, }) require.Equal(t, int64(10), sum.TotalHits) require.Equal(t, int64(7), sum.UnlocatedHits) // 未定位的那条没有坐标,不能进城市聚合(否则会画到 0,0)。 require.Len(t, sum.Cities, 1) require.Equal(t, "日本", sum.Cities[0].Country) // 未定位的来源国家名必须是「未知」而不是空串 require.Equal(t, unknownCountry, sum.Sources[0].Country) } // TestBuildCloudSources 覆盖「云主机 vs 肉鸡」的区分。 func TestBuildCloudSources(t *testing.T) { loc := time.Local start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} sum := Build(Input{ Period: period, Sources: []*db.HoneypotSourceAgg{ {IP: "203.0.113.1", Hits: 1, Provider: "微软 Azure(云厂商)"}, {IP: "203.0.113.2", Hits: 1, Provider: "未识别(普通网络)"}, {IP: "203.0.113.3", Hits: 1, Provider: "识别失败"}, {IP: "203.0.113.4", Hits: 1, Provider: ""}, }, Now: start, }) // 只有第一条算云主机:剩下三种都是「不知道」,不能当成云。 require.Equal(t, 1, sum.CloudSources) } // TestBuildEmpty 覆盖空窗口:报告仍要能生成(「这周没人打」是个结论)。 func TestBuildEmpty(t *testing.T) { loc := time.Local start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} sum := Build(Input{Period: period, Now: start}) require.True(t, sum.Empty) require.Equal(t, int64(0), sum.TotalHits) // 每一天都要有 0 值占位,否则趋势图会把「无数据」画成断档。 require.Len(t, sum.Days, 7) } // TestBuildDailySeries 覆盖每日趋势:无命中的日子必须补 0。 func TestBuildDailySeries(t *testing.T) { loc := time.Local start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) period := Period{Kind: KindWeek, Key: "week:2026-W43", Start: start, End: start.AddDate(0, 0, 7)} stamps := []time.Time{ start.Add(2 * time.Hour), // 10-19 start.Add(3 * time.Hour), // 10-19 start.AddDate(0, 0, 4).Add(time.Hour), // 10-23 // 边界外:下一周期的第一天第一秒不能算进来(左闭右开) period.End, } sum := Build(Input{Period: period, Timestamps: stamps, Now: start}) require.Len(t, sum.Days, 7) require.Equal(t, int64(2), sum.Days[0].Hits) require.Equal(t, int64(0), sum.Days[1].Hits) require.Equal(t, int64(1), sum.Days[4].Hits) require.Equal(t, int64(2), sum.MaxDay) // TotalHits 由「来源聚合」得出,与「时间戳聚合」是两条独立输入; // 本用例只给了时间戳,所以 TotalHits 为 0。真实调用里两者同窗口同表, // 数值必定一致(见 internal/cmd/report.go 的 buildSecurityReport)。 require.Equal(t, int64(0), sum.TotalHits) }