conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项的语义是
**不剥离**行尾注释:`port = 1440 ; 注释` 解析出来的值是 "1440 ; 注释",
int 解析失败,而 MapTo 仍然返回 nil error —— 配置项静默回退零值。
实测照抄 app.sample.ini 的后果:
- text_censor_source 的值带注释,被 validateTextCensorSource 拒绝,直接起不来;
- ip_blacklist / security_alert_email 变成带注释的垃圾字符串;
- honeypot_max_rows / honeypot_auto_clean 静默失效(后者靠 sanitize 兜底才没出事)。
改动:
- 17 处 `key = value ; 注释` 改成「注释独占一行 + 裸值」,保留 CRLF;
- 蜜罐段补 honeypot_cred_dir,以及「提交凭据才封禁」的语义说明;
- 新增 TestSampleIniValuesHaveNoInlineComment:遍历所有段的键,断言值不含 ';'
(比逐字段断言彻底,新增配置项自动纳入)。
变异验证:给 ban_window_seconds 加回行尾注释,测试按预期失败;
- keynorm_test 补 HoneypotBanMinutes 的缺省值断言(缺省 1440 / 显式 0 交给 autoban 回退)。
463 lines
15 KiB
Go
463 lines
15 KiB
Go
// 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 conf
|
||
|
||
import (
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"gopkg.in/ini.v1"
|
||
)
|
||
|
||
// TestNormalizeKeyName 覆盖键名归一化的各种手写失误。
|
||
//
|
||
// 现场背景:运维写成 `honeypot_retention_days`(多一个 t)且写成
|
||
// `honeypot_retention_days <= 30`,ini 把键名解析成
|
||
// "honeypot_retention_days <"(带尾随比较符),完全匹配不上任何配置项,
|
||
// 静默失效 —— 表现为「自动清理未启用」,现场毫无线索。
|
||
func TestNormalizeKeyName(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
in string
|
||
want string
|
||
}{
|
||
{"正常键名不变", "honeypot_retain_days", "honeypot_retain_days"},
|
||
{"多一个 t 的误拼", "honeypot_retention_days", "honeypot_retain_days"},
|
||
{"误拼加尾随比较符", "honeypot_retention_days <", "honeypot_retain_days"},
|
||
{"尾随 <= ", "honeypot_retain_days<=", "honeypot_retain_days"},
|
||
{"首尾空白", " honeypot_retain_days ", "honeypot_retain_days"},
|
||
{"空白加比较符", " honeypot_retain_days < ", "honeypot_retain_days"},
|
||
{"多个尾随符号", "honeypot_retain_days<< ", "honeypot_retain_days"},
|
||
{"空串", "", ""},
|
||
{"纯符号", "<<<", ""},
|
||
{"大小写不归一", "HONEYPOT_RETAIN_DAYS", "HONEYPOT_RETAIN_DAYS"},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
if got := normalizeKeyName(c.in); got != c.want {
|
||
t.Errorf("normalizeKeyName(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestNormalizeSectionKeys 验证段内键名改写:错拼的键被纠正到正确名字。
|
||
func TestNormalizeSectionKeys(t *testing.T) {
|
||
src := `
|
||
[security]
|
||
enable_honeypot = true
|
||
honeypot_retention_days <= 30
|
||
honeypot_auto_clean = true
|
||
`
|
||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||
if err != nil {
|
||
t.Fatalf("parse ini: %v", err)
|
||
}
|
||
sec := f.Section("security")
|
||
legal := legalKeysOf(&Security)
|
||
|
||
renamed := normalizeSectionKeys(sec, legal)
|
||
if len(renamed) == 0 {
|
||
t.Fatalf("期望有键名被改写,实际没有")
|
||
}
|
||
if !sec.HasKey("honeypot_retain_days") {
|
||
t.Errorf("改写后应存在 honeypot_retain_days,实际键: %v", sec.KeyStrings())
|
||
}
|
||
// 值必须原样保留(30),不能因为改写丢值。
|
||
if got := sec.Key("honeypot_retain_days").Value(); got != "30" {
|
||
t.Errorf("改写后值 = %q, want %q", got, "30")
|
||
}
|
||
// 归一化后的键名必须真的能被 MapTo 读到 —— 这是整个修复的落点。
|
||
if err := sec.MapTo(&Security); err != nil {
|
||
t.Fatalf("map security: %v", err)
|
||
}
|
||
if Security.HoneypotRetainDays != 30 {
|
||
t.Errorf("HoneypotRetainDays = %d, want 30", Security.HoneypotRetainDays)
|
||
}
|
||
}
|
||
|
||
// TestNormalizeSectionKeysKeepsExplicit 验证正确键名存在时不会被别名的值覆盖。
|
||
//
|
||
// 显式写对的键优先级必须高于需要猜测的别名 —— 否则运维改了正确写法反而失效。
|
||
func TestNormalizeSectionKeysKeepsExplicit(t *testing.T) {
|
||
src := `
|
||
[security]
|
||
honeypot_retain_days = 7
|
||
honeypot_retention_days = 30
|
||
`
|
||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||
if err != nil {
|
||
t.Fatalf("parse ini: %v", err)
|
||
}
|
||
sec := f.Section("security")
|
||
normalizeSectionKeys(sec, legalKeysOf(&Security))
|
||
|
||
if got := sec.Key("honeypot_retain_days").Value(); got != "7" {
|
||
t.Errorf("显式键应保留原值, got %q, want %q", got, "7")
|
||
}
|
||
}
|
||
|
||
// TestNormalizeSectionKeysUnknownNotGuessed 验证无法识别的键不会被「猜」成某个配置项。
|
||
//
|
||
// 归一化只能纠正「意图明确」的写法;对真正拼错的键必须保持原样,
|
||
// 交由未知键告警提示 —— 擅自猜测会让配置静默变成另一个意思,比不猜更危险。
|
||
func TestNormalizeSectionKeysUnknownNotGuessed(t *testing.T) {
|
||
src := `
|
||
[security]
|
||
totally_bogus_key = 1
|
||
`
|
||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||
if err != nil {
|
||
t.Fatalf("parse ini: %v", err)
|
||
}
|
||
sec := f.Section("security")
|
||
normalizeSectionKeys(sec, legalKeysOf(&Security))
|
||
|
||
if !sec.HasKey("totally_bogus_key") {
|
||
t.Errorf("无法识别的键应保持原样,不应被改写;实际键: %v", sec.KeyStrings())
|
||
}
|
||
}
|
||
|
||
// TestDetectUnknownSections 验证未知键被检出并给出建议。
|
||
func TestDetectUnknownSections(t *testing.T) {
|
||
src := `
|
||
[security]
|
||
enable_honeypot = true
|
||
honeypot_retention_days = 30
|
||
honeypot_totally_bogus = 1
|
||
`
|
||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||
if err != nil {
|
||
t.Fatalf("parse ini: %v", err)
|
||
}
|
||
unknown := detectUnknownSections(f, sectionKeySets())
|
||
|
||
var sec *unknownSection
|
||
for i := range unknown {
|
||
if unknown[i].Section == "security" {
|
||
sec = &unknown[i]
|
||
}
|
||
}
|
||
if sec == nil {
|
||
t.Fatalf("应检出 security 段的未知键,实际: %+v", unknown)
|
||
}
|
||
found := false
|
||
for _, k := range sec.Keys {
|
||
if k == "honeypot_totally_bogus" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("未知键列表应含 honeypot_totally_bogus,实际: %v", sec.Keys)
|
||
}
|
||
}
|
||
|
||
// TestDetectUnknownSectionsNoFalsePositive 验证合法键不会被误报为未知。
|
||
//
|
||
// 误报比漏报更烦人:每次启动都刷一屏 WARN,运维会麻木。
|
||
func TestDetectUnknownSectionsNoFalsePositive(t *testing.T) {
|
||
src := `
|
||
[app]
|
||
external_url = "http://127.0.0.1:8080"
|
||
production = true
|
||
|
||
[security]
|
||
enable_honeypot = true
|
||
honeypot_auto_clean = true
|
||
honeypot_max_rows = 200000
|
||
honeypot_retain_days = 30
|
||
honeypot_threshold = 1
|
||
honeypot_ban_minutes = 15
|
||
honeypot_paths = "/a,/b"
|
||
enable_auto_ban = true
|
||
ban_threshold_sensitive = 5
|
||
security_alert_email = "[email protected]"
|
||
|
||
[server]
|
||
port = 8080
|
||
salt = "s"
|
||
xsrf_key = "k"
|
||
|
||
[database]
|
||
type = "postgres"
|
||
name = "x"
|
||
schema = "public"
|
||
|
||
[limits]
|
||
max_tags = 5
|
||
`
|
||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||
if err != nil {
|
||
t.Fatalf("parse ini: %v", err)
|
||
}
|
||
unknown := detectUnknownSections(f, sectionKeySets())
|
||
for _, u := range unknown {
|
||
// [service] 段是子段结构,合法键在前缀段上,不在本段。
|
||
if u.Section == "service" {
|
||
continue
|
||
}
|
||
t.Errorf("合法键被误报为未知: 段 %s 键 %v", u.Section, u.Keys)
|
||
}
|
||
}
|
||
|
||
// TestClosestKey 验证建议算法能给出有用的近似键名。
|
||
func TestClosestKey(t *testing.T) {
|
||
legal := map[string]bool{
|
||
"honeypot_retain_days": true,
|
||
"honeypot_max_rows": true,
|
||
"enable_honeypot": true,
|
||
}
|
||
if got := closestKey("honeypot_retention_days", legal); got != "honeypot_retain_days" {
|
||
t.Errorf("closestKey = %q, want %q", got, "honeypot_retain_days")
|
||
}
|
||
if got := closestKey("enable_honeypot_hit", legal); got != "enable_honeypot" {
|
||
t.Errorf("closestKey = %q, want %q", got, "enable_honeypot")
|
||
}
|
||
// 毫无关系的键不应给建议(避免噪音)。
|
||
if got := closestKey("zzzzzz", legal); got != "" {
|
||
t.Errorf("closestKey 应返回空, got %q", got)
|
||
}
|
||
}
|
||
|
||
// TestSanitizeHoneypotDefaults 验证蜜罐配置的缺省值与语义修正。
|
||
//
|
||
// 核心是三件事:
|
||
// 1. 未配置保留期 → 回退 30 天(而不是 0 = 永不清理);
|
||
// 2. 显式 honeypot_auto_clean = false → 尊重为关闭(这是明确的关闭意图);
|
||
// 3. 未配置自动清理 → 开启(ini 的 bool 零值是 false,若不区分就等于默认关闭)。
|
||
func TestSanitizeHoneypotDefaults(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
src string
|
||
wantRetain int
|
||
wantMaxRows int
|
||
wantAutoClean bool
|
||
wantThreshold int
|
||
wantBan int
|
||
}{
|
||
{
|
||
name: "全未配置时回退安全默认值",
|
||
src: "[security]\nenable_honeypot = true\n",
|
||
wantRetain: DefaultHoneypotRetainDays,
|
||
wantMaxRows: DefaultHoneypotMaxRows,
|
||
wantAutoClean: true,
|
||
wantThreshold: DefaultHoneypotThreshold,
|
||
wantBan: DefaultHoneypotBanMinutes,
|
||
},
|
||
{
|
||
name: "显式关闭自动清理被尊重",
|
||
src: "[security]\nhoneypot_auto_clean = false\n",
|
||
wantRetain: DefaultHoneypotRetainDays,
|
||
wantMaxRows: DefaultHoneypotMaxRows,
|
||
wantAutoClean: false,
|
||
wantThreshold: DefaultHoneypotThreshold,
|
||
wantBan: DefaultHoneypotBanMinutes,
|
||
},
|
||
{
|
||
name: "显式保留期被采用",
|
||
src: "[security]\nhoneypot_retain_days = 7\n",
|
||
wantRetain: 7,
|
||
wantMaxRows: DefaultHoneypotMaxRows,
|
||
wantAutoClean: true,
|
||
wantThreshold: DefaultHoneypotThreshold,
|
||
wantBan: DefaultHoneypotBanMinutes,
|
||
},
|
||
{
|
||
name: "保留期写成 0 回退默认而非永不清理",
|
||
src: "[security]\nhoneypot_retain_days = 0\n",
|
||
wantRetain: DefaultHoneypotRetainDays,
|
||
wantMaxRows: DefaultHoneypotMaxRows,
|
||
wantAutoClean: true,
|
||
wantThreshold: DefaultHoneypotThreshold,
|
||
wantBan: DefaultHoneypotBanMinutes,
|
||
},
|
||
{
|
||
name: "行数上限显式 0 表示不限",
|
||
src: "[security]\nhoneypot_max_rows = 0\n",
|
||
wantRetain: DefaultHoneypotRetainDays,
|
||
wantMaxRows: 0,
|
||
wantAutoClean: true,
|
||
wantThreshold: DefaultHoneypotThreshold,
|
||
wantBan: DefaultHoneypotBanMinutes,
|
||
},
|
||
{
|
||
name: "显式封禁时长被采用",
|
||
src: "[security]\nhoneypot_ban_minutes = 90\n",
|
||
wantRetain: DefaultHoneypotRetainDays,
|
||
wantMaxRows: DefaultHoneypotMaxRows,
|
||
wantAutoClean: true,
|
||
wantThreshold: DefaultHoneypotThreshold,
|
||
wantBan: 90,
|
||
},
|
||
{
|
||
// 0 是逃生口:留着让 autoban 回退 ban_duration_minutes,
|
||
// sanitize 不做覆盖(只告警),故期望值仍是 0。
|
||
name: "封禁时长写 0 交给 autoban 回退 ban_duration_minutes",
|
||
src: "[security]\nhoneypot_ban_minutes = 0\n",
|
||
wantRetain: DefaultHoneypotRetainDays,
|
||
wantMaxRows: DefaultHoneypotMaxRows,
|
||
wantAutoClean: true,
|
||
wantThreshold: DefaultHoneypotThreshold,
|
||
wantBan: 0,
|
||
},
|
||
}
|
||
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(c.src))
|
||
if err != nil {
|
||
t.Fatalf("parse ini: %v", err)
|
||
}
|
||
// 重置全局,避免用例间互相污染。
|
||
Security.HoneypotRetainDays = 0
|
||
Security.HoneypotMaxRows = 0
|
||
Security.HoneypotAutoClean = false
|
||
Security.HoneypotThreshold = 0
|
||
Security.HoneypotBanMinutes = 0
|
||
File = f
|
||
if err := f.Section("security").MapTo(&Security); err != nil {
|
||
t.Fatalf("map security: %v", err)
|
||
}
|
||
sanitizeHoneypot()
|
||
|
||
if Security.HoneypotRetainDays != c.wantRetain {
|
||
t.Errorf("HoneypotRetainDays = %d, want %d", Security.HoneypotRetainDays, c.wantRetain)
|
||
}
|
||
if Security.HoneypotMaxRows != c.wantMaxRows {
|
||
t.Errorf("HoneypotMaxRows = %d, want %d", Security.HoneypotMaxRows, c.wantMaxRows)
|
||
}
|
||
if Security.HoneypotAutoClean != c.wantAutoClean {
|
||
t.Errorf("HoneypotAutoClean = %v, want %v", Security.HoneypotAutoClean, c.wantAutoClean)
|
||
}
|
||
if Security.HoneypotThreshold != c.wantThreshold {
|
||
t.Errorf("HoneypotThreshold = %d, want %d", Security.HoneypotThreshold, c.wantThreshold)
|
||
}
|
||
if Security.HoneypotBanMinutes != c.wantBan {
|
||
t.Errorf("HoneypotBanMinutes = %d, want %d", Security.HoneypotBanMinutes, c.wantBan)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestLegalKeysOfCoversStruct 验证合法键集合确实覆盖了结构体的所有 ini tag。
|
||
//
|
||
// 这是「未知键告警」不误报的前提:如果 legalKeysOf 漏了某个 tag,
|
||
// 每次启动都会误报该键。
|
||
func TestLegalKeysOfCoversStruct(t *testing.T) {
|
||
legal := legalKeysOf(&Security)
|
||
for _, want := range []string{
|
||
"enable_honeypot",
|
||
"honeypot_paths",
|
||
"honeypot_retain_days",
|
||
"honeypot_auto_clean",
|
||
"honeypot_max_rows",
|
||
"honeypot_threshold",
|
||
"honeypot_ban_minutes",
|
||
} {
|
||
if !legal[want] {
|
||
t.Errorf("合法键集合缺少 %q", want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestSampleIniHasNoUnknownKeys 用官方样例配置文件做回归检测。
|
||
//
|
||
// 意义:conf/app.sample.ini 是运维照抄的模板,它里面的每个键都必须能被代码识别。
|
||
// 一旦结构体改了字段名而 sample.ini 没同步(或反之),这个测试就会失败 ——
|
||
// 而不是等到某次部署时启动日志里飘出一堆「无法识别的键」让人摸不着头脑。
|
||
//
|
||
// 同时也保证未知键检测不会对官方模板误报。
|
||
func TestSampleIniHasNoUnknownKeys(t *testing.T) {
|
||
path := filepath.Join("..", "..", "conf", "app.sample.ini")
|
||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, path)
|
||
if err != nil {
|
||
t.Skipf("跳过:无法读取样例配置 %s(%v)", path, err)
|
||
}
|
||
unknown := detectUnknownSections(f, sectionKeySets())
|
||
for _, u := range unknown {
|
||
// [service] 是子段结构(每个后端一个子段),键在子段上,不在本段。
|
||
if u.Section == "service" {
|
||
continue
|
||
}
|
||
for _, k := range u.Keys {
|
||
t.Errorf("样例配置中存在无法识别的键: 段 %s 键 %q", u.Section, k)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestSampleIniValuesHaveNoInlineComment 守住「行尾注释会被并入值」的陷阱。
|
||
//
|
||
// conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项的语义是
|
||
// **不剥离**行尾注释(实测 `port = 1440 ; 注释` → 值 "1440 ; 注释",
|
||
// int 解析失败,而 MapTo 仍然返回 nil error)。后果是该配置项静默回退零值,
|
||
// 日志里只会说「配置值不对」,根本联想不到是注释造成的。
|
||
//
|
||
// 样例配置曾经全线是 `key = value ; 注释` 的写法:text_censor_source 因此
|
||
// 让 conf.Init 直接拒绝启动,ip_blacklist / security_alert_email 变成带注释的
|
||
// 垃圾字符串,honeypot_max_rows / honeypot_auto_clean 静默失效。
|
||
//
|
||
// 直接检查每个键的值里没有 ';',比逐个字段断言更彻底,也不会漏掉新加的配置项。
|
||
func TestSampleIniValuesHaveNoInlineComment(t *testing.T) {
|
||
path := filepath.Join("..", "..", "conf", "app.sample.ini")
|
||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, path)
|
||
if err != nil {
|
||
t.Skipf("跳过:无法读取样例配置 %s(%v)", path, err)
|
||
}
|
||
for _, sec := range f.Sections() {
|
||
for _, k := range sec.Keys() {
|
||
if strings.Contains(k.Value(), ";") {
|
||
t.Errorf("样例配置 [%s] %s 的值含 ';':%q —— 行尾注释会被并入值,该项将静默失效",
|
||
sec.Name(), k.Name(), k.Value())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNormalizeAllSectionKeysEndToEnd 走完整链路:
|
||
// 加载带错拼键的配置 → 归一化 → MapTo → 拿到正确的保留期。
|
||
//
|
||
// 这正是现场故障的完整复现路径。
|
||
func TestNormalizeAllSectionKeysEndToEnd(t *testing.T) {
|
||
src := `
|
||
[security]
|
||
enable_honeypot = true
|
||
honeypot_auto_clean = true
|
||
honeypot_max_rows = 200000
|
||
honeypot_retention_days <= 30
|
||
`
|
||
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
|
||
if err != nil {
|
||
t.Fatalf("parse ini: %v", err)
|
||
}
|
||
File = f
|
||
Security.HoneypotRetainDays = 0
|
||
Security.HoneypotAutoClean = false
|
||
Security.HoneypotMaxRows = 0
|
||
Security.HoneypotThreshold = 0
|
||
|
||
normalizeAllSectionKeys(f)
|
||
if err := f.Section("security").MapTo(&Security); err != nil {
|
||
t.Fatalf("map security: %v", err)
|
||
}
|
||
sanitizeHoneypot()
|
||
|
||
if Security.HoneypotRetainDays != 30 {
|
||
t.Errorf("HoneypotRetainDays = %d, want 30(错拼键应被纠正并生效)", Security.HoneypotRetainDays)
|
||
}
|
||
if !Security.HoneypotAutoClean {
|
||
t.Errorf("HoneypotAutoClean = false, want true")
|
||
}
|
||
if Security.HoneypotMaxRows != 200000 {
|
||
t.Errorf("HoneypotMaxRows = %d, want 200000", Security.HoneypotMaxRows)
|
||
}
|
||
// 归一化后不应再残留「带比较符」的怪键名。
|
||
for _, k := range f.Section("security").KeyStrings() {
|
||
if strings.ContainsAny(k, "<>") {
|
||
t.Errorf("残留带比较符的键名: %q", k)
|
||
}
|
||
}
|
||
}
|