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) }) } }