// 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 = "a@b.c" [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) } } }