package config import ( "os" "path/filepath" "testing" ) // writeConfig 写一份最小可用配置并加载它。 func writeConfig(t *testing.T, extra string) *Config { t.Helper() body := ` system: data-dir: /tmp/idsync-test server: admin-user: admin admin-password: pw feishu: app-id: a app-secret: b ldap: url: ldap://localhost:389 base-dn: "dc=example,dc=org" admin-dn: "cn=admin,dc=example,dc=org" admin-pass: pw ` + extra dir := t.TempDir() path := filepath.Join(dir, "config.yml") if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("写配置失败: %v", err) } cfg, err := Load(path) if err != nil { t.Fatalf("加载配置失败: %v", err) } return cfg } // TestGroupSchemaDefaultsToUniqueMember 验证默认形态保持兼容。 func TestGroupSchemaDefaultsToUniqueMember(t *testing.T) { cfg := writeConfig(t, "") if cfg.Ldap.GroupObjectClass != "groupOfUniqueNames" { t.Errorf("默认 objectClass = %q", cfg.Ldap.GroupObjectClass) } if cfg.Ldap.GroupMemberAttr != "uniqueMember" { t.Errorf("默认成员属性 = %q", cfg.Ldap.GroupMemberAttr) } } // TestGroupSchemaDerivesMemberAttr 验证只写 objectClass 时能自动推导出成员属性。 // // 这是实际使用中最容易出错的地方:groupOfNames 必须配 member、 // groupOfUniqueNames 必须配 uniqueMember,写错会被 LDAP 以 // objectClassViolation 拒绝,而错误信息很不直观。 func TestGroupSchemaDerivesMemberAttr(t *testing.T) { cases := []struct { objectClass string wantAttr string }{ {"groupOfNames", "member"}, {"groupOfUniqueNames", "uniqueMember"}, // 大小写不敏感 {"GroupOfNames", "member"}, {"GROUPOFUNIQUENAMES", "uniqueMember"}, } for _, c := range cases { t.Run(c.objectClass, func(t *testing.T) { cfg := writeConfig(t, " group-object-class: \""+c.objectClass+"\"\n") if cfg.Ldap.GroupMemberAttr != c.wantAttr { t.Errorf("objectClass=%s 推导出的成员属性 = %q, 期望 %q", c.objectClass, cfg.Ldap.GroupMemberAttr, c.wantAttr) } }) } } // TestGroupSchemaRejectsMismatch 验证 objectClass 与成员属性不匹配时直接报错。 // // 静默容忍会让问题推迟到运行时,表现为分组建好了但成员查不到, // 排查成本远高于启动即失败。 func TestGroupSchemaRejectsMismatch(t *testing.T) { cases := []struct { name string body string }{ { name: "groupOfNames 配 uniqueMember", body: " group-object-class: \"groupOfNames\"\n group-member-attr: \"uniqueMember\"\n", }, { name: "groupOfUniqueNames 配 member", body: " group-object-class: \"groupOfUniqueNames\"\n group-member-attr: \"member\"\n", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yml") body := ` system: data-dir: /tmp/idsync-test server: admin-user: admin admin-password: pw feishu: app-id: a app-secret: b ldap: url: ldap://localhost:389 base-dn: "dc=example,dc=org" admin-dn: "cn=admin,dc=example,dc=org" admin-pass: pw ` + c.body if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("写配置失败: %v", err) } if _, err := Load(path); err == nil { t.Errorf("不匹配的组合应被拒绝,却加载成功") } }) } } // TestDerivedDNs 验证各 OU 拼接结果正确。 func TestDerivedDNs(t *testing.T) { cfg := writeConfig(t, " user-ou: \"ou=users\"\n inactive-ou: \"ou=Inactive\"\n group-root-name: \"feishuroot\"\n") if got := cfg.UserOUDN(); got != "ou=users,dc=example,dc=org" { t.Errorf("UserOUDN = %q", got) } if got := cfg.InactiveOUDN(); got != "ou=Inactive,dc=example,dc=org" { t.Errorf("InactiveOUDN = %q", got) } if got := cfg.GroupRootDN(); got != "ou=feishuroot,dc=example,dc=org" { t.Errorf("GroupRootDN = %q", got) } } // TestInactiveOUOutsideUserOU 是一个架构约束测试。 // // ou=Inactive 必须位于用户 OU 之外,否则 Authelia(其 base 为 additional_users_dn) // 依然能搜到离职用户,停用机制就失效了。 func TestInactiveOUOutsideUserOU(t *testing.T) { cfg := writeConfig(t, " user-ou: \"ou=users\"\n inactive-ou: \"ou=Inactive\"\n") userDN := cfg.UserOUDN() inactiveDN := cfg.InactiveOUDN() if inactiveDN == userDN { t.Fatalf("离职 OU 与用户 OU 相同: %s", inactiveDN) } // 离职 OU 不应位于用户 OU 之下 if len(inactiveDN) > len(userDN) && inactiveDN[len(inactiveDN)-len(userDN):] == userDN { t.Errorf("离职 OU (%s) 位于用户 OU (%s) 之下,Authelia 仍能搜到离职用户", inactiveDN, userDN) } } // TestNormalizeRejectsMissingCredentials 验证关键字段缺失时启动即失败。 func TestNormalizeRejectsMissingCredentials(t *testing.T) { cases := []struct { name string over string }{ {"缺少 app-id", " app-id: \"\"\n"}, {"缺少 base-dn", "BASE_DN_MARKER"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yml") feishuID, baseDN := "app", `"dc=example,dc=org"` if c.name == "缺少 app-id" { feishuID = "" } if c.name == "缺少 base-dn" { baseDN = `""` } body := ` system: data-dir: /tmp/idsync-test server: admin-user: admin admin-password: pw feishu: app-id: "` + feishuID + `" app-secret: b ldap: url: ldap://localhost:389 base-dn: ` + baseDN + ` admin-dn: "cn=admin,dc=example,dc=org" admin-pass: pw ` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("写配置失败: %v", err) } if _, err := Load(path); err == nil { t.Errorf("缺少必要配置项时应加载失败") } }) } }