Files
gochat/internal/auth/ldap_test.go
T
2026-06-04 15:44:48 +08:00

528 lines
18 KiB
Go

package auth
import (
"context"
"encoding/json"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// --- Helpers for LDAP tests ---
func newTestLDAPDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
err = db.AutoMigrate(&model.AccountLDAPSettings{}, &model.User{}, &model.Account{}, &model.AccountUser{})
require.NoError(t, err)
return db
}
func newTestLDAPConfig(enabled bool) *config.LDAPConfig {
return &config.LDAPConfig{
Enabled: enabled,
DefaultHost: "localhost",
DefaultPort: 389,
DefaultUseTLS: false,
DefaultBaseDN: "dc=example,dc=com",
DefaultBindDN: "cn=admin,dc=example,dc=com",
DefaultBindPassword: "adminpassword",
DefaultUserFilter: "(uid=%s)",
DefaultEmailAttribute: "mail",
DefaultNameAttribute: "cn",
DefaultGroupAttribute: "memberOf",
SyncInterval: 300,
}
}
// --- NewLDAPService tests ---
func TestNewLDAPService_Disabled(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
defer rdb.Close()
db := newTestLDAPDB(t)
cfg := newTestLDAPConfig(false)
svc, err := NewLDAPService(cfg, rdb, db)
assert.NoError(t, err, "disabled LDAP should not error")
assert.NotNil(t, svc)
}
func TestNewLDAPService_EnabledValidConfig(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
defer rdb.Close()
db := newTestLDAPDB(t)
cfg := newTestLDAPConfig(true)
svc, err := NewLDAPService(cfg, rdb, db)
assert.NoError(t, err, "valid LDAP config should not error")
assert.NotNil(t, svc)
}
func TestNewLDAPService_MissingBaseDN(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
defer rdb.Close()
db := newTestLDAPDB(t)
cfg := newTestLDAPConfig(true)
cfg.DefaultHost = "ldap.example.com"
cfg.DefaultBaseDN = ""
_, err := NewLDAPService(cfg, rdb, db)
assert.ErrorIs(t, err, ErrLDAPInvalidConfig, "host set but empty BaseDN should error")
}
func TestNewLDAPService_EmptyHost(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
defer rdb.Close()
db := newTestLDAPDB(t)
cfg := newTestLDAPConfig(true)
cfg.DefaultHost = ""
// Empty host with BaseDN set — should succeed (per-account config can override)
svc, err := NewLDAPService(cfg, rdb, db)
assert.NoError(t, err, "empty host is valid when BaseDN is also empty (defer to per-account)")
assert.NotNil(t, svc)
}
// --- extractCNFromDN tests ---
func TestExtractCNFromDN(t *testing.T) {
assert.Equal(t, "admins", extractCNFromDN("cn=admins,ou=groups,dc=example,dc=com"))
assert.Equal(t, "John Doe", extractCNFromDN("cn=John Doe,ou=people,dc=example,dc=com"))
assert.Equal(t, "user1", extractCNFromDN("CN=user1,dc=example,dc=com"), "case-insensitive CN prefix")
}
func TestExtractCNFromDN_NoCN(t *testing.T) {
// No cn= prefix in DN — should return full DN as fallback
assert.Equal(t, "ou=groups,dc=example,dc=com", extractCNFromDN("ou=groups,dc=example,dc=com"))
}
func TestExtractCNFromDN_Empty(t *testing.T) {
assert.Equal(t, "", extractCNFromDN(""))
}
func TestExtractCNFromDN_SingleCN(t *testing.T) {
assert.Equal(t, "admin", extractCNFromDN("cn=admin"))
}
// --- getAttr tests ---
func TestGetAttr(t *testing.T) {
attrs := map[string]string{"mail": "user@example.com", "cn": "Test User"}
assert.Equal(t, "user@example.com", getAttr(attrs, "mail", "fallback"))
assert.Equal(t, "Test User", getAttr(attrs, "cn", "fallback"))
assert.Equal(t, "fallback", getAttr(attrs, "missing", "fallback"), "missing key should return fallback")
assert.Equal(t, "", getAttr(attrs, "missing", ""), "missing key with empty fallback should return empty string")
}
// --- MapLDAPGroupsToRoles tests ---
func TestMapLDAPGroupsToRoles_AdminGroup(t *testing.T) {
settings := &model.AccountLDAPSettings{
RoleMappings: json.RawMessage(`{"admins": "administrator", "developers": "agent"}`),
}
role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"cn=admins,ou=groups,dc=example,dc=com"})
assert.Equal(t, "administrator", role, "admins group should map to administrator")
}
func TestMapLDAPGroupsToRoles_MultipleGroups(t *testing.T) {
settings := &model.AccountLDAPSettings{
RoleMappings: json.RawMessage(`{"admins": "administrator", "developers": "agent", "managers": "supervisor"}`),
}
role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{
"cn=developers,ou=groups,dc=example,dc=com",
"cn=admins,ou=groups,dc=example,dc=com",
})
assert.Equal(t, "administrator", role, "highest privilege group should win")
}
func TestMapLDAPGroupsToRoles_SupervisorAndAgent(t *testing.T) {
settings := &model.AccountLDAPSettings{
RoleMappings: json.RawMessage(`{"managers": "supervisor", "developers": "agent"}`),
}
role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{
"cn=managers,ou=groups,dc=example,dc=com",
"cn=developers,ou=groups,dc=example,dc=com",
})
assert.Equal(t, "supervisor", role, "supervisor should beat agent")
}
func TestMapLDAPGroupsToRoles_NoMappings(t *testing.T) {
settings := &model.AccountLDAPSettings{
RoleMappings: nil,
}
role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"cn=admins,ou=groups,dc=example,dc=com"})
assert.Equal(t, "agent", role, "no mappings should default to agent")
}
func TestMapLDAPGroupsToRoles_EmptyMappings(t *testing.T) {
settings := &model.AccountLDAPSettings{
RoleMappings: json.RawMessage(``),
}
role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"cn=admins,ou=groups,dc=example,dc=com"})
assert.Equal(t, "agent", role, "empty mappings should default to agent")
}
func TestMapLDAPGroupsToRoles_InvalidJSONMappings(t *testing.T) {
settings := &model.AccountLDAPSettings{
RoleMappings: json.RawMessage(`invalid json`),
}
role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"cn=admins,ou=groups,dc=example,dc=com"})
assert.Equal(t, "agent", role, "invalid JSON should default to agent")
}
func TestMapLDAPGroupsToRoles_NoMatchingGroup(t *testing.T) {
settings := &model.AccountLDAPSettings{
RoleMappings: json.RawMessage(`{"admins": "administrator"}`),
}
role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"cn=unknown,ou=groups,dc=example,dc=com"})
assert.Equal(t, "agent", role, "no matching group should default to agent")
}
func TestMapLDAPGroupsToRoles_DirectDNMapping(t *testing.T) {
settings := &model.AccountLDAPSettings{
RoleMappings: json.RawMessage(`{"cn=admins,ou=groups,dc=example,dc=com": "administrator"}`),
}
role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"cn=admins,ou=groups,dc=example,dc=com"})
assert.Equal(t, "administrator", role, "should match by full DN as well as CN")
}
func TestMapLDAPGroupsToRoles_EmptyGroupsList(t *testing.T) {
settings := &model.AccountLDAPSettings{
RoleMappings: json.RawMessage(`{"admins": "administrator"}`),
}
role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{})
assert.Equal(t, "agent", role, "empty groups list should default to agent")
}
func TestMapLDAPGroupsToRoles_AdminVsAdministrator(t *testing.T) {
settings := &model.AccountLDAPSettings{
RoleMappings: json.RawMessage(`{"superadmins": "administrator", "admins": "admin"}`),
}
// administrator (priority 4) > admin (priority 3)
role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{
"cn=admins,ou=groups,dc=example,dc=com",
"cn=superadmins,ou=groups,dc=example,dc=com",
})
assert.Equal(t, "administrator", role, "administrator should beat admin in priority")
}
// --- BER encoding tests ---
func TestBerEncodeInteger(t *testing.T) {
// Zero
result := berEncodeInteger(0)
assert.Equal(t, []byte{0x02, 0x01, 0x00}, result, "zero integer encoding")
// Small positive integer
result = berEncodeInteger(1)
assert.Equal(t, byte(0x02), result[0], "should have INTEGER tag")
assert.Equal(t, byte(0x01), result[1], "length should be 1")
assert.Equal(t, byte(0x01), result[2], "value should be 1")
// Larger integer
result = berEncodeInteger(255)
assert.Equal(t, byte(0x02), result[0])
// 255 = 0x00FF, needs 2 bytes (leading zero for positive sign)
assert.True(t, len(result) >= 3, "255 needs at least 3 bytes")
}
func TestBerEncodeEnumerated(t *testing.T) {
result := berEncodeEnumerated(0)
assert.Equal(t, byte(0x0a), result[0], "ENUMERATED tag should be 0x0a")
assert.Equal(t, byte(0x01), result[1], "length should be 1")
assert.Equal(t, byte(0x00), result[2], "value should be 0")
result = berEncodeEnumerated(2)
assert.Equal(t, byte(0x0a), result[0])
}
func TestBerEncodeOctetString(t *testing.T) {
// Empty string
result := berEncodeOctetString("")
assert.Equal(t, []byte{0x04, 0x00}, result, "empty string encoding")
// Short string (< 128 chars)
result = berEncodeOctetString("hello")
assert.Equal(t, byte(0x04), result[0], "should have OCTET STRING tag")
assert.Equal(t, byte(0x05), result[1], "length should be 5")
assert.Equal(t, []byte("hello"), result[2:], "value should be 'hello'")
// Long string (>= 128 chars) — uses long form length
longStr := string(make([]byte, 200))
result = berEncodeOctetString(longStr)
assert.Equal(t, byte(0x04), result[0])
// Long form: second byte has high bit set
assert.True(t, result[1]&0x80 != 0, "long form length should have high bit set")
}
func TestBerEncodeBoolean(t *testing.T) {
result := berEncodeBoolean(true)
assert.Equal(t, []byte{0x01, 0x01, 0xFF}, result, "TRUE encoding")
result = berEncodeBoolean(false)
assert.Equal(t, []byte{0x01, 0x01, 0x00}, result, "FALSE encoding")
}
func TestBerEncodeSequence(t *testing.T) {
// Empty sequence
result := berEncodeSequence()
assert.Equal(t, []byte{0x30, 0x00}, result, "empty sequence encoding")
// Sequence with one element
elem := berEncodeInteger(1)
result = berEncodeSequence(elem)
assert.Equal(t, byte(0x30), result[0], "SEQUENCE tag")
assert.Equal(t, byte(byte(len(elem))), result[1], "length should match element size")
// Sequence with multiple elements
elem1 := berEncodeInteger(1)
elem2 := berEncodeOctetString("test")
result = berEncodeSequence(elem1, elem2)
assert.Equal(t, byte(0x30), result[0])
expectedLen := len(elem1) + len(elem2)
assert.Equal(t, byte(expectedLen), result[1], "length should be sum of elements")
}
func TestBerEncodeApplicationTag(t *testing.T) {
content := berEncodeOctetString("test")
result := berEncodeApplicationTag(0, content)
// Application tag 0: class=01 (application), constructed=1, tag=0 → 0x60
assert.Equal(t, byte(0x60), result[0], "Application tag 0 should be 0x60")
assert.Equal(t, byte(len(content)), result[1], "length should match content")
}
func TestBerEncodeApplicationTag_HigherTag(t *testing.T) {
content := berEncodeOctetString("test")
result := berEncodeApplicationTag(3, content)
// Application tag 3: 0x40 | 0x20 | 0x03 = 0x63
assert.Equal(t, byte(0x63), result[0], "Application tag 3 should be 0x63")
}
func TestBerEncodeContextTag(t *testing.T) {
content := berEncodeOctetString("test")
result := berEncodeContextTag(0, content)
// Context tag 0: class=10 (context), constructed=1, tag=0 → 0xA0
assert.Equal(t, byte(0xA0), result[0], "Context tag 0 should be 0xA0")
assert.Equal(t, byte(len(content)), result[1], "length should match content")
}
func TestBerEncodeContextTag_HigherTag(t *testing.T) {
content := berEncodeOctetString("test")
result := berEncodeContextTag(3, content)
// Context tag 3: 0x80 | 0x20 | 0x03 = 0xA3
assert.Equal(t, byte(0xA3), result[0], "Context tag 3 should be 0xA3")
}
// --- LDAP Bind Request encoding test ---
func TestEncodeLDAPBindRequest(t *testing.T) {
result := encodeLDAPBindRequest(1, 3, "cn=admin,dc=example,dc=com", "password")
// The result should start with a SEQUENCE (0x30)
assert.Equal(t, byte(0x30), result[0], "bind request should start with SEQUENCE")
// Total length should be reasonable
assert.True(t, len(result) > 20, "bind request should have meaningful length")
// Should contain the integer message ID (1)
// Inside the sequence: first element is messageID integer
assert.Equal(t, byte(0x02), result[2], "should contain INTEGER tag for messageID")
}
func TestEncodeLDAPBindRequest_DifferentMessageID(t *testing.T) {
result1 := encodeLDAPBindRequest(1, 3, "cn=user1,dc=example,dc=com", "pass1")
result2 := encodeLDAPBindRequest(2, 3, "cn=user1,dc=example,dc=com", "pass1")
// Different message IDs should produce different encodings
assert.NotEqual(t, result1, result2, "different message IDs should produce different encodings")
}
// --- LDAP Search Request encoding test ---
func TestEncodeLDAPSearchRequest(t *testing.T) {
result := encodeLDAPSearchRequest(
2, // messageID
"dc=example,dc=com", // baseDN
2, // scope (wholeSubtree)
0, // derefAliases (never)
0, // sizeLimit
0, // timeLimit
false, // typesOnly
"(uid=testuser)", // filter
[]string{"mail", "cn"}, // attributes
)
// Should start with SEQUENCE
assert.Equal(t, byte(0x30), result[0], "search request should start with SEQUENCE")
assert.True(t, len(result) > 30, "search request should have meaningful length")
}
func TestEncodeLDAPSearchRequest_EmptyAttributes(t *testing.T) {
result := encodeLDAPSearchRequest(
1, "dc=example,dc=com", 0, 0, 0, 0, false, "(objectClass=*)", []string{},
)
assert.Equal(t, byte(0x30), result[0])
assert.True(t, len(result) > 10, "even empty attributes should produce valid encoding")
}
// --- LDAP error sentinel tests ---
func TestLDAPErrorSentinels(t *testing.T) {
assert.Equal(t, "ldap authentication is not enabled", ErrLDAPDisabled.Error())
assert.Equal(t, "ldap configuration is invalid", ErrLDAPInvalidConfig.Error())
assert.Equal(t, "failed to connect to ldap server", ErrLDAPConnection.Error())
assert.Equal(t, "ldap bind authentication failed", ErrLDAPBindFailed.Error())
assert.Equal(t, "ldap user not found", ErrLDAPUserNotFound.Error())
assert.Equal(t, "ldap search failed", ErrLDAPSearchFailed.Error())
}
// --- LDAP Authenticate tests (disabled) ---
func TestLDAPService_Authenticate_Disabled(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
defer rdb.Close()
db := newTestLDAPDB(t)
cfg := newTestLDAPConfig(false)
svc, err := NewLDAPService(cfg, rdb, db)
require.NoError(t, err)
_, err = svc.Authenticate(context.Background(), 1, "uid", "password")
assert.ErrorIs(t, err, ErrLDAPDisabled, "disabled service should reject authentication")
}
// --- LDAP SyncGroups tests (disabled) ---
func TestLDAPService_SyncGroups_Disabled(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
defer rdb.Close()
db := newTestLDAPDB(t)
cfg := newTestLDAPConfig(false)
svc, err := NewLDAPService(cfg, rdb, db)
require.NoError(t, err)
err = svc.SyncGroups(nil, 1)
assert.ErrorIs(t, err, ErrLDAPDisabled, "disabled service should reject sync")
}
// --- LDAP per-account settings tests ---
func TestLDAPService_WithPerAccountSettings(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
defer rdb.Close()
db := newTestLDAPDB(t)
account := &model.Account{Name: "Test Corp", Active: true}
require.NoError(t, db.Create(account).Error)
settings := &model.AccountLDAPSettings{
AccountID: account.ID,
Host: "ldap.corp.example.com",
Port: 636,
UseTLS: true,
BaseDN: "dc=corp,dc=example,dc=com",
BindDN: "cn=admin,dc=corp,dc=example,dc=com",
BindPassword: "corppassword",
UserFilter: "(uid=%s)",
EmailAttribute: "mail",
NameAttribute: "cn",
GroupAttribute: "memberOf",
RoleMappings: json.RawMessage(`{"admins": "administrator"}`),
AutoProvision: true,
Active: true,
}
require.NoError(t, db.Create(settings).Error)
cfg := newTestLDAPConfig(true)
svc, err := NewLDAPService(cfg, rdb, db)
require.NoError(t, err)
assert.NotNil(t, svc)
}
// --- LDAPUserInfo struct test ---
func TestLDAPUserInfo(t *testing.T) {
info := LDAPUserInfo{
DN: "cn=testuser,ou=people,dc=example,dc=com",
Email: "testuser@example.com",
DisplayName: "Test User",
FirstName: "Test",
LastName: "User",
Groups: []string{"cn=admins,ou=groups,dc=example,dc=com"},
Attributes: map[string]string{"mail": "testuser@example.com", "cn": "Test User"},
}
assert.Equal(t, "cn=testuser,ou=people,dc=example,dc=com", info.DN)
assert.Equal(t, "testuser@example.com", info.Email)
assert.Equal(t, []string{"cn=admins,ou=groups,dc=example,dc=com"}, info.Groups)
assert.Equal(t, "Test User", info.Attributes["cn"])
}
// --- LDAP role priority constants verification ---
func TestLDAPRolePriority(t *testing.T) {
// Verify role priority is: administrator > admin > supervisor > agent
// via MapLDAPGroupsToRoles behavior
settings := &model.AccountLDAPSettings{
RoleMappings: json.RawMessage(`{
"grp1": "administrator",
"grp2": "admin",
"grp3": "supervisor",
"grp4": "agent"
}`),
}
// administrator should win over all
role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"grp1", "grp2", "grp3", "grp4"})
assert.Equal(t, "administrator", role)
// admin should win over supervisor and agent
role = (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"grp2", "grp3", "grp4"})
assert.Equal(t, "admin", role)
// supervisor should win over agent
role = (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"grp3", "grp4"})
assert.Equal(t, "supervisor", role)
// agent alone
role = (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"grp4"})
assert.Equal(t, "agent", role)
}