From d21ca0658fc82a13abdcfef03230da4d79e49b46 Mon Sep 17 00:00:00 2001 From: Ralf Haferkamp Date: Tue, 23 Nov 2021 14:47:46 +0100 Subject: [PATCH] graph: Add unit test for LDAP identity backend This reworks the LDAP backend a bit to allow for mocking the ldap.Client interface. It also add a couple of unit test for the backend --- graph/pkg/identity/ldap.go | 19 +- graph/pkg/identity/ldap/reconnect.go | 68 ++++++ graph/pkg/identity/ldap_test.go | 307 +++++++++++++++++++++++++++ graph/pkg/service/v0/service.go | 9 +- 4 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 graph/pkg/identity/ldap_test.go diff --git a/graph/pkg/identity/ldap.go b/graph/pkg/identity/ldap.go index 98bb226500..3114b00859 100644 --- a/graph/pkg/identity/ldap.go +++ b/graph/pkg/identity/ldap.go @@ -9,7 +9,6 @@ import ( msgraph "github.com/yaegashi/msgraph.go/beta" "github.com/owncloud/ocis/graph/pkg/config" - ldaputil "github.com/owncloud/ocis/graph/pkg/identity/ldap" "github.com/owncloud/ocis/graph/pkg/service/v0/errorcode" "github.com/owncloud/ocis/ocis-pkg/log" ) @@ -26,7 +25,7 @@ type LDAP struct { groupAttributeMap groupAttributeMap logger *log.Logger - conn *ldaputil.ConnWithReconnect + conn ldap.Client } type userAttributeMap struct { @@ -41,14 +40,21 @@ type groupAttributeMap struct { id string } -func NewLDAPBackend(config config.LDAP, logger *log.Logger) (*LDAP, error) { - conn := ldaputil.NewLDAPWithReconnect(logger, config.URI, config.BindDN, config.BindPassword) +func NewLDAPBackend(lc ldap.Client, config config.LDAP, logger *log.Logger) (*LDAP, error) { + if config.UserDisplayNameAttribute == "" || config.UserIDAttribute == "" || + config.UserEmailAttribute == "" || config.UserNameAttribute == "" { + return nil, fmt.Errorf("Invalid user attribute mappings") + } uam := userAttributeMap{ displayName: config.UserDisplayNameAttribute, id: config.UserIDAttribute, mail: config.UserEmailAttribute, userName: config.UserNameAttribute, } + + if config.GroupNameAttribute == "" || config.GroupIDAttribute == "" { + return nil, fmt.Errorf("Invalid group attribute mappings") + } gam := groupAttributeMap{ name: config.GroupNameAttribute, id: config.GroupIDAttribute, @@ -74,7 +80,7 @@ func NewLDAPBackend(config config.LDAP, logger *log.Logger) (*LDAP, error) { groupScope: groupScope, groupAttributeMap: gam, logger: logger, - conn: &conn, + conn: lc, }, nil } @@ -228,6 +234,9 @@ func (i *LDAP) GetGroups(ctx context.Context, queryParam url.Values) ([]*msgraph } func (i *LDAP) createUserModelFromLDAP(e *ldap.Entry) *msgraph.User { + if e == nil { + return nil + } return &msgraph.User{ DisplayName: pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.displayName)), Mail: pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.mail)), diff --git a/graph/pkg/identity/ldap/reconnect.go b/graph/pkg/identity/ldap/reconnect.go index cfb333ad95..f929e57a56 100644 --- a/graph/pkg/identity/ldap/reconnect.go +++ b/graph/pkg/identity/ldap/reconnect.go @@ -4,7 +4,10 @@ package ldap // https://gist.github.com/emsearcy/cba3295d1a06d4c432ab4f6173b65e4f#file-ldap_snippet-go import ( + "crypto/tls" "errors" + "fmt" + "time" "github.com/go-ldap/ldap/v3" @@ -16,6 +19,7 @@ type ldapConnection struct { Error error } +// Implements the ldap.CLient interface type ConnWithReconnect struct { conn chan ldapConnection reset chan *ldap.Conn @@ -125,3 +129,67 @@ func (c ConnWithReconnect) reconnect(resetConn *ldap.Conn) (*ldap.Conn, error) { result := <-c.conn return result.Conn, result.Error } + +// Remaining methods to fulfill ldap.Client interface + +func (c ConnWithReconnect) Start() {} + +func (c ConnWithReconnect) StartTLS(*tls.Config) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ConnWithReconnect) Close() {} + +func (c ConnWithReconnect) IsClosing() bool { + return false +} + +func (c ConnWithReconnect) SetTimeout(time.Duration) {} + +func (c ConnWithReconnect) Bind(username, password string) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ConnWithReconnect) UnauthenticatedBind(username string) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ConnWithReconnect) SimpleBind(*ldap.SimpleBindRequest) (*ldap.SimpleBindResult, error) { + return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ConnWithReconnect) ExternalBind() error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ConnWithReconnect) Add(*ldap.AddRequest) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ConnWithReconnect) Del(*ldap.DelRequest) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ConnWithReconnect) Modify(*ldap.ModifyRequest) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ConnWithReconnect) ModifyDN(*ldap.ModifyDNRequest) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ConnWithReconnect) ModifyWithResult(*ldap.ModifyRequest) (*ldap.ModifyResult, error) { + return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ConnWithReconnect) Compare(dn, attribute, value string) (bool, error) { + return false, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ConnWithReconnect) PasswordModify(*ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) { + return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ConnWithReconnect) SearchWithPaging(searchRequest *ldap.SearchRequest, pagingSize uint32) (*ldap.SearchResult, error) { + return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} diff --git a/graph/pkg/identity/ldap_test.go b/graph/pkg/identity/ldap_test.go new file mode 100644 index 0000000000..41afac8a11 --- /dev/null +++ b/graph/pkg/identity/ldap_test.go @@ -0,0 +1,307 @@ +package identity + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net/url" + "testing" + "time" + + "github.com/go-ldap/ldap/v3" + "github.com/owncloud/ocis/graph/pkg/config" + "github.com/owncloud/ocis/ocis-pkg/log" +) + +// ldapMock implements the ldap.Client interfac +type ldapMock struct { + SearchFunc *searchFunc +} + +type searchFunc func(*ldap.SearchRequest) (*ldap.SearchResult, error) + +func getMockedBackend(sf *searchFunc, lc config.LDAP, logger *log.Logger) (*LDAP, error) { + // Mock a Sizelimit Error + lm := ldapMock{SearchFunc: sf} + return NewLDAPBackend(lm, lconfig, logger) +} + +var lconfig = config.LDAP{ + UserBaseDN: "dc=test", + UserSearchScope: "sub", + UserFilter: "filter", + UserDisplayNameAttribute: "displayname", + UserIDAttribute: "entryUUID", + UserEmailAttribute: "mail", + UserNameAttribute: "uid", + + GroupBaseDN: "dc=test", + GroupSearchScope: "sub", + GroupFilter: "filter", + GroupNameAttribute: "cn", + GroupIDAttribute: "entryUUID", +} + +var userEntry = ldap.NewEntry("uid=user", + map[string][]string{ + "uid": {"user"}, + "displayname": {"DisplayName"}, + "mail": {"user@example"}, + "entryuuid": {"abcd-defg"}, + }) +var groupEntry = ldap.NewEntry("cn=group", + map[string][]string{ + "cn": {"group"}, + "entryuuid": {"abcd-defg"}, + }) + +var logger = log.NewLogger(log.Level("debug")) + +func TestNewLDAPBackend(t *testing.T) { + + l := ldapMock{} + + tc := lconfig + tc.UserDisplayNameAttribute = "" + if _, err := NewLDAPBackend(l, tc, &logger); err == nil { + t.Error("Should fail with incomplete user attr config") + } + + tc = lconfig + tc.GroupIDAttribute = "" + if _, err := NewLDAPBackend(l, tc, &logger); err == nil { + t.Errorf("Should fail with incomplete group config") + } + + tc = lconfig + tc.UserSearchScope = "" + if _, err := NewLDAPBackend(l, tc, &logger); err == nil { + t.Errorf("Should fail with invalid user search scope") + } + + tc = lconfig + tc.GroupSearchScope = "" + if _, err := NewLDAPBackend(l, tc, &logger); err == nil { + t.Errorf("Should fail with invalid group search scope") + } + + if _, err := NewLDAPBackend(l, lconfig, &logger); err != nil { + t.Errorf("Should fail with invalid group search scope") + } + +} + +func TestCreateUserModelFromLDAP(t *testing.T) { + l := ldapMock{} + logger := log.NewLogger(log.Level("debug")) + + b, _ := NewLDAPBackend(l, lconfig, &logger) + if user := b.createUserModelFromLDAP(nil); user != nil { + t.Errorf("createUserModelFromLDAP should return on nil Entry") + } + user := b.createUserModelFromLDAP(userEntry) + if user == nil { + t.Error("Converting a valid LDAP Entry should succeed") + } else { + if *user.OnPremisesSamAccountName != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.userName) { + t.Errorf("Error creating msGraph User from LDAP Entry: %s != %s", *user.OnPremisesSamAccountName, userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.userName)) + } + if *user.Mail != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.mail) { + t.Errorf("Error creating msGraph User from LDAP Entry: %s != %s", *user.Mail, userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.mail)) + } + if *user.DisplayName != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.displayName) { + t.Errorf("Error creating msGraph User from LDAP Entry: %s != %s", *user.DisplayName, userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.displayName)) + } + if *user.ID != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id) { + t.Errorf("Error creating msGraph User from LDAP Entry: %s != %s", *user.ID, userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id)) + } + } +} + +func TestGetUser(t *testing.T) { + // Mock a Sizelimit Error + var sf searchFunc = func(*ldap.SearchRequest) (*ldap.SearchResult, error) { + return nil, ldap.NewError(ldap.LDAPResultSizeLimitExceeded, errors.New("mock")) + } + b, _ := getMockedBackend(&sf, lconfig, &logger) + _, err := b.GetUser(context.Background(), "fred") + if err == nil || err.Error() != "itemNotFound" { + t.Errorf("Expected 'itemNotFound' got '%s'", err.Error()) + } + + // Mock an empty Search Result + sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) { + return &ldap.SearchResult{}, nil + } + b, _ = getMockedBackend(&sf, lconfig, &logger) + _, err = b.GetUser(context.Background(), "fred") + if err == nil || err.Error() != "itemNotFound" { + t.Errorf("Expected 'itemNotFound' got '%s'", err.Error()) + } + + // Mock a valid Search Result + sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) { + return &ldap.SearchResult{ + Entries: []*ldap.Entry{userEntry}, + }, nil + } + b, _ = getMockedBackend(&sf, lconfig, &logger) + u, err := b.GetUser(context.Background(), "user") + if err != nil { + t.Errorf("Expected GetUser to succeed. Got %s", err.Error()) + } else if *u.ID != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id) { + t.Errorf("Expected GetUser to return a valid user") + } +} + +func TestGetUsers(t *testing.T) { + var sf searchFunc = func(*ldap.SearchRequest) (*ldap.SearchResult, error) { + return nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock")) + } + b, _ := getMockedBackend(&sf, lconfig, &logger) + _, err := b.GetUsers(context.Background(), url.Values{}) + if err == nil || err.Error() != "itemNotFound" { + t.Errorf("Expected 'itemNotFound' got '%s'", err.Error()) + } + + sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) { + return &ldap.SearchResult{}, nil + } + b, _ = getMockedBackend(&sf, lconfig, &logger) + g, err := b.GetUsers(context.Background(), url.Values{}) + if err != nil { + t.Errorf("Expected success, got '%s'", err.Error()) + } else if g == nil || len(g) != 0 { + t.Errorf("Expected zero length user slice") + } +} + +func TestGetGroup(t *testing.T) { + // Mock a Sizelimit Error + var sf searchFunc = func(*ldap.SearchRequest) (*ldap.SearchResult, error) { + return nil, ldap.NewError(ldap.LDAPResultSizeLimitExceeded, errors.New("mock")) + } + b, _ := getMockedBackend(&sf, lconfig, &logger) + _, err := b.GetGroup(context.Background(), "group") + if err == nil || err.Error() != "itemNotFound" { + t.Errorf("Expected 'itemNotFound' got '%s'", err.Error()) + } + + // Mock an empty Search Result + sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) { + return &ldap.SearchResult{}, nil + } + b, _ = getMockedBackend(&sf, lconfig, &logger) + _, err = b.GetGroup(context.Background(), "group") + if err == nil || err.Error() != "itemNotFound" { + t.Errorf("Expected 'itemNotFound' got '%s'", err.Error()) + } + + // Mock a valid Search Result + sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) { + return &ldap.SearchResult{ + Entries: []*ldap.Entry{groupEntry}, + }, nil + } + b, _ = getMockedBackend(&sf, lconfig, &logger) + g, err := b.GetGroup(context.Background(), "group") + if err != nil { + t.Errorf("Expected GetGroup to succeed. Got %s", err.Error()) + } else if *g.ID != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) { + t.Errorf("Expected GetGroup to return a valid group") + } +} + +func TestGetGroups(t *testing.T) { + var sf searchFunc = func(*ldap.SearchRequest) (*ldap.SearchResult, error) { + return nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock")) + } + b, _ := getMockedBackend(&sf, lconfig, &logger) + _, err := b.GetGroups(context.Background(), url.Values{}) + if err == nil || err.Error() != "itemNotFound" { + t.Errorf("Expected 'itemNotFound' got '%s'", err.Error()) + } + + sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) { + return &ldap.SearchResult{}, nil + } + b, _ = getMockedBackend(&sf, lconfig, &logger) + g, err := b.GetGroups(context.Background(), url.Values{}) + if err != nil { + t.Errorf("Expected success, got '%s'", err.Error()) + } else if g == nil || len(g) != 0 { + t.Errorf("Expected zero length user slice") + } +} + +// below here ldap.Client interface method for ldapMock + +func (c ldapMock) Start() {} + +func (c ldapMock) StartTLS(*tls.Config) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ldapMock) Close() {} + +func (c ldapMock) IsClosing() bool { + return false +} + +func (c ldapMock) SetTimeout(time.Duration) {} + +func (c ldapMock) Bind(username, password string) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ldapMock) UnauthenticatedBind(username string) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ldapMock) SimpleBind(*ldap.SimpleBindRequest) (*ldap.SimpleBindResult, error) { + return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ldapMock) ExternalBind() error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ldapMock) Add(*ldap.AddRequest) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ldapMock) Del(*ldap.DelRequest) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ldapMock) Modify(*ldap.ModifyRequest) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ldapMock) ModifyDN(*ldap.ModifyDNRequest) error { + return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ldapMock) ModifyWithResult(*ldap.ModifyRequest) (*ldap.ModifyResult, error) { + return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ldapMock) Compare(dn, attribute, value string) (bool, error) { + return false, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ldapMock) PasswordModify(*ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) { + return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} + +func (c ldapMock) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) { + if c.SearchFunc != nil { + return (*c.SearchFunc)(searchRequest) + } + + return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} +func (c ldapMock) SearchWithPaging(searchRequest *ldap.SearchRequest, pagingSize uint32) (*ldap.SearchResult, error) { + return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented")) +} diff --git a/graph/pkg/service/v0/service.go b/graph/pkg/service/v0/service.go index e0ded49aed..e722d160b9 100644 --- a/graph/pkg/service/v0/service.go +++ b/graph/pkg/service/v0/service.go @@ -5,7 +5,9 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/owncloud/ocis/graph/pkg/identity" + "github.com/owncloud/ocis/graph/pkg/identity/ldap" "github.com/owncloud/ocis/ocis-pkg/account" opkgm "github.com/owncloud/ocis/ocis-pkg/middleware" ) @@ -34,7 +36,12 @@ func NewService(opts ...Option) Service { } case "ldap": var err error - if backend, err = identity.NewLDAPBackend(options.Config.Identity.LDAP, &options.Logger); err != nil { + conn := ldap.NewLDAPWithReconnect(&options.Logger, + options.Config.Identity.LDAP.URI, + options.Config.Identity.LDAP.BindDN, + options.Config.Identity.LDAP.BindPassword, + ) + if backend, err = identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger); err != nil { options.Logger.Error().Msgf("Error initializing LDAP Backend: '%s'", err) return nil }