Files

566 lines
16 KiB
Go

package ldapsync
import (
"net/http"
"testing"
"github.com/go-ldap/ldap/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
type fakeLDAPClient struct {
searchFn func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
}
func TestCreateLDAPClientRejectsDisabledConfiguration(t *testing.T) {
svc := newService(Dependencies{})
_, err := svc.createClient(&appconfig.AppConfigModel{LdapEnabled: "false"})
require.True(t, apperror.IsCode(err, apperror.CodeLdapDisabled))
}
func (c *fakeLDAPClient) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
if c.searchFn == nil {
return nil, nil
}
return c.searchFn(searchRequest)
}
func (c *fakeLDAPClient) Bind(_, _ string) error {
return nil
}
func (c *fakeLDAPClient) Close() error {
return nil
}
func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Jones"},
"displayName": {""},
}),
ldapEntry("uid=bob,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-bob"},
"uid": {"bob"},
"mail": {"bob@example.com"},
"givenName": {"Bob"},
"sn": {"Brown"},
"displayName": {""},
}),
),
ldapSearchResult(
ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-admins"},
"cn": {"admins"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
}),
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-team"},
"cn": {"team"},
"member": {
"UID=Alice, OU=People, DC=example, DC=com",
"uid=bob, ou=people, dc=example, dc=com",
},
}),
),
))
aliceLdapID := "u-alice"
missingLdapID := "u-missing"
teamLdapID := "g-team"
oldGroupLdapID := "g-old"
require.NoError(t, db.Create(&model.User{
Username: "alice-old",
Email: new("alice-old@example.com"),
EmailVerified: true,
FirstName: "Old",
LastName: "Name",
DisplayName: "Old Name",
LdapID: &aliceLdapID,
Disabled: true,
}).Error)
require.NoError(t, db.Create(&model.User{
Username: "missing",
Email: new("missing@example.com"),
EmailVerified: true,
FirstName: "Missing",
LastName: "User",
DisplayName: "Missing User",
LdapID: &missingLdapID,
}).Error)
require.NoError(t, db.Create(&model.UserGroup{
Name: "team-old",
FriendlyName: "team-old",
LdapID: &teamLdapID,
}).Error)
require.NoError(t, db.Create(&model.UserGroup{
Name: "old-group",
FriendlyName: "old-group",
LdapID: &oldGroupLdapID,
}).Error)
err := service.SyncAll(t.Context(), defaultTestLDAPAppConfig())
require.NoError(t, err)
var alice model.User
require.NoError(t, db.First(&alice, "ldap_id = ?", aliceLdapID).Error)
assert.Equal(t, "alice", alice.Username)
assert.Equal(t, new("alice@example.com"), alice.Email)
assert.Equal(t, "Alice", alice.FirstName)
assert.Equal(t, "Jones", alice.LastName)
assert.Equal(t, "Alice Jones", alice.DisplayName)
assert.True(t, alice.IsAdmin)
assert.False(t, alice.Disabled)
var bob model.User
require.NoError(t, db.First(&bob, "ldap_id = ?", "u-bob").Error)
assert.Equal(t, "bob", bob.Username)
assert.Equal(t, "Bob Brown", bob.DisplayName)
var missing model.User
require.NoError(t, db.First(&missing, "ldap_id = ?", missingLdapID).Error)
assert.True(t, missing.Disabled)
var oldGroupCount int64
require.NoError(t, db.Model(&model.UserGroup{}).Where("ldap_id = ?", oldGroupLdapID).Count(&oldGroupCount).Error)
assert.Zero(t, oldGroupCount)
var team model.UserGroup
require.NoError(t, db.Preload("Users").First(&team, "ldap_id = ?", teamLdapID).Error)
assert.Equal(t, "team", team.Name)
assert.Equal(t, "team", team.FriendlyName)
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(team.Users))
}
// Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408.
func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
appCfg := defaultTestLDAPAppConfig()
appCfg.LdapUserGroupSearchFilter = "(objectClass=posixGroup)"
appCfg.LdapAttributeGroupMember = "memberUid"
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=users,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Jones"},
"displayName": {""},
}),
ldapEntry("uid=bob,ou=users,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-bob"},
"uid": {"bob"},
"mail": {"bob@example.com"},
"givenName": {"Bob"},
"sn": {"Brown"},
"displayName": {""},
}),
),
ldapSearchResult(
ldapEntry("cn=users,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-users"},
"cn": {"users"},
"memberUid": {"alice", "bob", "unknown"},
}),
),
))
err := service.SyncAll(t.Context(), appCfg)
require.NoError(t, err)
var group model.UserGroup
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", "g-users").Error)
assert.Equal(t, "users", group.Name)
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(group.Users))
}
func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) {
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-dup"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Doe"},
"displayName": {"Alice Doe"},
}),
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-dup"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alicia"},
"sn": {"Doe"},
"displayName": {"Alicia Doe"},
}),
),
ldapSearchResult(
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-dup"},
"cn": {"team"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
}),
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-dup"},
"cn": {"team-renamed"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
}),
),
))
err := service.SyncAll(t.Context(), defaultTestLDAPAppConfig())
require.NoError(t, err)
var users []model.User
require.NoError(t, db.Find(&users, "ldap_id = ?", "u-dup").Error)
require.Len(t, users, 1)
assert.Equal(t, "alice", users[0].Username)
assert.Equal(t, "Alicia", users[0].FirstName)
assert.Equal(t, "Alicia Doe", users[0].DisplayName)
var groups []model.UserGroup
require.NoError(t, db.Preload("Users").Find(&groups, "ldap_id = ?", "g-dup").Error)
require.Len(t, groups, 1)
assert.Equal(t, "team-renamed", groups[0].Name)
assert.Equal(t, "team-renamed", groups[0].FriendlyName)
assert.ElementsMatch(t, []string{"alice"}, usernames(groups[0].Users))
}
func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
tests := []struct {
name string
appConfig *appconfig.AppConfigModel
groupEntry *ldap.Entry
groupName string
groupLookup string
}{
{
name: "memberOf missing on user",
appConfig: defaultTestLDAPAppConfig(),
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-admins"},
"cn": {"admins"},
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
}),
groupName: "admins",
groupLookup: "g-admins",
},
{
name: "configured group name attribute differs from DN RDN",
appConfig: func() *appconfig.AppConfigModel {
cfg := defaultTestLDAPAppConfig()
cfg.LdapAttributeGroupName = "displayName"
cfg.LdapAdminGroupName = "pocketid.admin"
return cfg
}(),
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-display-admins"},
"cn": {"admins"},
"displayName": {"pocketid.admin"},
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
}),
groupName: "pocketid.admin",
groupLookup: "g-display-admins",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=testadmin,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-testadmin"},
"uid": {"testadmin"},
"mail": {"testadmin@example.com"},
"givenName": {"Test"},
"sn": {"Admin"},
"displayName": {""},
}),
),
ldapSearchResult(tt.groupEntry),
))
err := service.SyncAll(t.Context(), tt.appConfig)
require.NoError(t, err)
var user model.User
require.NoError(t, db.First(&user, "ldap_id = ?", "u-testadmin").Error)
assert.True(t, user.IsAdmin)
var group model.UserGroup
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", tt.groupLookup).Error)
assert.Equal(t, tt.groupName, group.Name)
assert.ElementsMatch(t, []string{"testadmin"}, usernames(group.Users))
})
}
}
func newTestLdapService(t *testing.T, client ldapClient) (*Service, *gorm.DB) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
fileStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
// The sync is exercised against the real user and group services, so the assertions below can check what actually lands in the database
groupService := service.NewUserGroupService(db, nil)
userService := service.NewUserService(
db,
nil,
nil,
service.NewCustomClaimService(db),
service.NewAppImagesService(map[string]string{}, fileStorage),
nil,
fileStorage,
)
svc := newService(Dependencies{
DB: db,
HTTPClient: &http.Client{},
FileStorage: fileStorage,
Users: userService,
Groups: groupService,
})
svc.clientFactory = func(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
return client, nil
}
return svc, db
}
func defaultTestLDAPAppConfig() *appconfig.AppConfigModel {
return &appconfig.AppConfigModel{
RequireUserEmail: "false",
LdapEnabled: "true",
LdapBase: "dc=example,dc=com",
LdapUserSearchFilter: "(objectClass=person)",
LdapUserGroupSearchFilter: "(objectClass=groupOfNames)",
LdapAttributeUserUniqueIdentifier: "entryUUID",
LdapAttributeUserUsername: "uid",
LdapAttributeUserEmail: "mail",
LdapAttributeUserFirstName: "givenName",
LdapAttributeUserLastName: "sn",
LdapAttributeUserDisplayName: "displayName",
LdapAttributeUserProfilePicture: "jpegPhoto",
LdapAttributeGroupMember: "member",
LdapAttributeGroupUniqueIdentifier: "entryUUID",
LdapAttributeGroupName: "cn",
LdapAdminGroupName: "admins",
LdapSoftDeleteUsers: "true",
}
}
func newFakeLDAPClient(userResult, groupResult *ldap.SearchResult) ldapClient {
return &fakeLDAPClient{
searchFn: func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
switch searchRequest.Filter {
case "(objectClass=person)":
return userResult, nil
case "(objectClass=groupOfNames)", "(objectClass=posixGroup)":
return groupResult, nil
default:
return &ldap.SearchResult{}, nil
}
},
}
}
func ldapSearchResult(entries ...*ldap.Entry) *ldap.SearchResult {
return &ldap.SearchResult{Entries: entries}
}
func ldapEntry(dn string, attrs map[string][]string) *ldap.Entry {
entry := &ldap.Entry{
DN: dn,
Attributes: make([]*ldap.EntryAttribute, 0, len(attrs)),
}
for name, values := range attrs {
entry.Attributes = append(entry.Attributes, &ldap.EntryAttribute{
Name: name,
Values: values,
})
}
return entry
}
func usernames(users []model.User) []string {
result := make([]string, 0, len(users))
for _, user := range users {
result = append(result, user.Username)
}
return result
}
func TestGetDNProperty(t *testing.T) {
tests := []struct {
name string
property string
dn string
expectedResult string
}{
{
name: "simple case",
property: "cn",
dn: "cn=username,ou=people,dc=example,dc=com",
expectedResult: "username",
},
{
name: "property not found",
property: "uid",
dn: "cn=username,ou=people,dc=example,dc=com",
expectedResult: "",
},
{
name: "mixed case property",
property: "CN",
dn: "cn=username,ou=people,dc=example,dc=com",
expectedResult: "username",
},
{
name: "mixed case DN",
property: "cn",
dn: "CN=username,OU=people,DC=example,DC=com",
expectedResult: "username",
},
{
name: "spaces in DN",
property: "cn",
dn: "cn=username, ou=people, dc=example, dc=com",
expectedResult: "username",
},
{
name: "value with special characters",
property: "cn",
dn: "cn=user.name+123,ou=people,dc=example,dc=com",
expectedResult: "user.name+123",
},
{
name: "empty DN",
property: "cn",
dn: "",
expectedResult: "",
},
{
name: "empty property",
property: "",
dn: "cn=username,ou=people,dc=example,dc=com",
expectedResult: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getDNProperty(tt.property, tt.dn)
assert.Equalf(t, tt.expectedResult, result, "getDNProperty(%q, %q)", tt.property, tt.dn)
})
}
}
func TestNormalizeLDAPDN(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "already normalized",
input: "cn=alice,dc=example,dc=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "uppercase attribute types",
input: "CN=Alice,DC=example,DC=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "spaces after commas",
input: "cn=alice, dc=example, dc=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "uppercase types and spaces",
input: "CN=Alice, DC=example, DC=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "multi-valued RDN",
input: "cn=alice+uid=a123,dc=example,dc=com",
expected: "cn=alice+uid=a123,dc=example,dc=com",
},
{
name: "invalid DN falls back to lowercase+trim",
input: " NOT A VALID DN ",
expected: "not a valid dn",
},
{
name: "empty string",
input: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := normalizeLDAPDN(tt.input)
assert.Equalf(t, tt.expected, result, "normalizeLDAPDN(%q)", tt.input)
})
}
}
func TestConvertLdapIdToString(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "valid UTF-8 string",
input: "simple-utf8-id",
expected: "simple-utf8-id",
},
{
name: "binary UUID (16 bytes)",
input: string([]byte{0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1}),
expected: "12345678-9abc-def0-1234-56789abcdef1",
},
{
name: "non-UTF8, non-UUID returns base64",
input: string([]byte{0xff, 0xfe, 0xfd, 0xfc}),
expected: "//79/A==",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertLdapIdToString(tt.input)
assert.Equal(t, tt.expected, got)
})
}
}