diff --git a/services/graph/pkg/identity/backend.go b/services/graph/pkg/identity/backend.go index b521e32d45d..5b8d5f5f444 100644 --- a/services/graph/pkg/identity/backend.go +++ b/services/graph/pkg/identity/backend.go @@ -60,6 +60,17 @@ type EducationBackend interface { // RemoveUserFromEducationSchool removes a single member (by ID) from a school RemoveUserFromEducationSchool(ctx context.Context, schoolID string, memberID string) error + // GetEducationClasses lists all classes + GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error) + // GetEducationClasses reads a given class by id + GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error) + // CreateEducationClass creates the supplied education class in the identity backend. + CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) + // DeleteEducationClass deletes the supplied education class in the identity backend. + DeleteEducationClass(ctx context.Context, nameOrID string) error + // GetEducationClassMembers returns the EducationUser members for an EducationClass + GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) + // CreateEducationUser creates a given education user in the identity backend. CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) // DeleteEducationUser deletes a given educationuser, identified by username or id, from the backend diff --git a/services/graph/pkg/identity/err_school.go b/services/graph/pkg/identity/err_education.go similarity index 75% rename from services/graph/pkg/identity/err_school.go rename to services/graph/pkg/identity/err_education.go index 6460fa46dfa..6e81f7c9c58 100644 --- a/services/graph/pkg/identity/err_school.go +++ b/services/graph/pkg/identity/err_education.go @@ -50,6 +50,31 @@ func (i *ErrEducationBackend) RemoveUserFromEducationSchool(ctx context.Context, return errNotImplemented } +// GetEducationClasses implements the EducationBackend interface +func (i *ErrEducationBackend) GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error) { + return nil, errNotImplemented +} + +// GetEducationClass implements the EducationBackend interface +func (i *ErrEducationBackend) GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error) { + return nil, errNotImplemented +} + +// CreateEducationClass implements the EducationBackend interface +func (i *ErrEducationBackend) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) { + return nil, errNotImplemented +} + +// DeleteEducationClass implements the EducationBackend interface +func (i *ErrEducationBackend) DeleteEducationClass(ctx context.Context, nameOrID string) error { + return errNotImplemented +} + +// GetEducationClassMembers implements the EducationBackend interface +func (i *ErrEducationBackend) GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) { + return nil, errNotImplemented +} + // CreateEducationUser creates a given education user in the identity backend. func (i *ErrEducationBackend) CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) { return nil, errNotImplemented diff --git a/services/graph/pkg/identity/ldap_education_class.go b/services/graph/pkg/identity/ldap_education_class.go new file mode 100644 index 00000000000..4fc3082bae2 --- /dev/null +++ b/services/graph/pkg/identity/ldap_education_class.go @@ -0,0 +1,33 @@ +package identity + +import ( + "context" + "net/url" + + libregraph "github.com/owncloud/libre-graph-api-go" +) + +// GetEducationClasses implements the EducationBackend interface for the LDAP backend. +func (i *LDAP) GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error) { + return nil, errNotImplemented +} + +// CreateEducationClass implements the EducationBackend interface for the LDAP backend. +func (i *LDAP) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) { + return nil, errNotImplemented +} + +// GetEducationClass implements the EducationBackend interface for the LDAP backend. +func (i *LDAP) GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error) { + return nil, errNotImplemented +} + +// DeleteEducationClass implements the EducationBackend interface for the LDAP backend. +func (i *LDAP) DeleteEducationClass(ctx context.Context, nameOrID string) error { + return errNotImplemented +} + +// GetEducationClassMembers implements the EducationBackend interface for the LDAP backend. +func (i *LDAP) GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) { + return nil, errNotImplemented +} diff --git a/services/graph/pkg/identity/ldap_school.go b/services/graph/pkg/identity/ldap_education_school.go similarity index 100% rename from services/graph/pkg/identity/ldap_school.go rename to services/graph/pkg/identity/ldap_education_school.go diff --git a/services/graph/pkg/identity/ldap_school_test.go b/services/graph/pkg/identity/ldap_education_school_test.go similarity index 100% rename from services/graph/pkg/identity/ldap_school_test.go rename to services/graph/pkg/identity/ldap_education_school_test.go diff --git a/services/graph/pkg/identity/mocks/education_backend.go b/services/graph/pkg/identity/mocks/education_backend.go index 772299ba9ef..2c2b4868b72 100644 --- a/services/graph/pkg/identity/mocks/education_backend.go +++ b/services/graph/pkg/identity/mocks/education_backend.go @@ -31,6 +31,29 @@ func (_m *EducationBackend) AddUsersToEducationSchool(ctx context.Context, schoo return r0 } +// CreateEducationClass provides a mock function with given fields: ctx, class +func (_m *EducationBackend) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) { + ret := _m.Called(ctx, class) + + var r0 *libregraph.EducationClass + if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationClass) *libregraph.EducationClass); ok { + r0 = rf(ctx, class) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*libregraph.EducationClass) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, libregraph.EducationClass) error); ok { + r1 = rf(ctx, class) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // CreateEducationSchool provides a mock function with given fields: ctx, group func (_m *EducationBackend) CreateEducationSchool(ctx context.Context, group libregraph.EducationSchool) (*libregraph.EducationSchool, error) { ret := _m.Called(ctx, group) @@ -77,6 +100,20 @@ func (_m *EducationBackend) CreateEducationUser(ctx context.Context, user libreg return r0, r1 } +// DeleteEducationClass provides a mock function with given fields: ctx, nameOrID +func (_m *EducationBackend) DeleteEducationClass(ctx context.Context, nameOrID string) error { + ret := _m.Called(ctx, nameOrID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(ctx, nameOrID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // DeleteEducationSchool provides a mock function with given fields: ctx, id func (_m *EducationBackend) DeleteEducationSchool(ctx context.Context, id string) error { ret := _m.Called(ctx, id) @@ -105,6 +142,75 @@ func (_m *EducationBackend) DeleteEducationUser(ctx context.Context, nameOrID st return r0 } +// GetEducationClass provides a mock function with given fields: ctx, namedOrID, queryParam +func (_m *EducationBackend) GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error) { + ret := _m.Called(ctx, namedOrID, queryParam) + + var r0 *libregraph.EducationClass + if rf, ok := ret.Get(0).(func(context.Context, string, url.Values) *libregraph.EducationClass); ok { + r0 = rf(ctx, namedOrID, queryParam) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*libregraph.EducationClass) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, url.Values) error); ok { + r1 = rf(ctx, namedOrID, queryParam) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetEducationClassMembers provides a mock function with given fields: ctx, nameOrID +func (_m *EducationBackend) GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) { + ret := _m.Called(ctx, nameOrID) + + var r0 []*libregraph.EducationUser + if rf, ok := ret.Get(0).(func(context.Context, string) []*libregraph.EducationUser); ok { + r0 = rf(ctx, nameOrID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*libregraph.EducationUser) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, nameOrID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetEducationClasses provides a mock function with given fields: ctx, queryParam +func (_m *EducationBackend) GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error) { + ret := _m.Called(ctx, queryParam) + + var r0 []*libregraph.EducationClass + if rf, ok := ret.Get(0).(func(context.Context, url.Values) []*libregraph.EducationClass); ok { + r0 = rf(ctx, queryParam) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*libregraph.EducationClass) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, url.Values) error); ok { + r1 = rf(ctx, queryParam) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetEducationSchool provides a mock function with given fields: ctx, nameOrID, queryParam func (_m *EducationBackend) GetEducationSchool(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationSchool, error) { ret := _m.Called(ctx, nameOrID, queryParam) diff --git a/services/graph/pkg/service/v0/educationclasses.go b/services/graph/pkg/service/v0/educationclasses.go new file mode 100644 index 00000000000..d50a1768eec --- /dev/null +++ b/services/graph/pkg/service/v0/educationclasses.go @@ -0,0 +1,433 @@ +package svc + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/CiscoM31/godata" + libregraph "github.com/owncloud/libre-graph-api-go" + "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +// GetEducationClasses implements the Service interface. +func (g Graph) GetEducationClasses(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Interface("query", r.URL.Query()).Msg("calling GetEducationClasses") + sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/") + odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query()) + if err != nil { + logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg( + "could not get educationClasses: query error") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } + + classes, err := g.identityEducationBackend.GetEducationClasses(r.Context(), r.URL.Query()) + if err != nil { + logger.Debug().Err(err).Msg("could not get classes: backend error") + var errcode errorcode.Error + if errors.As(err, &errcode) { + errcode.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + } + return + } + + classes, err = sortClasses(odataReq, classes) + if err != nil { + logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get classes: could not sort classes according to query") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } + render.Status(r, http.StatusOK) + render.JSON(w, r, &ListResponse{Value: classes}) +} + +// PostEducationClass implements the Service interface. +func (g Graph) PostEducationClass(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling post EducationClass") + class := libregraph.NewEducationClassWithDefaults() + err := json.NewDecoder(r.Body).Decode(class) + if err != nil { + logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create education class: invalid request body") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error())) + return + } + + if _, ok := class.GetDisplayNameOk(); !ok { + logger.Debug().Err(err).Interface("class", class).Msg("could not create class: missing required attribute") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute") + return + } + + // Disallow user-supplied IDs. It's supposed to be readonly. We're either + // generating them in the backend ourselves or rely on the Backend's + // storage (e.g. LDAP) to provide a unique ID. + if _, ok := class.GetIdOk(); ok { + logger.Debug().Msg("could not create class: id is a read-only attribute") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "class id is a read-only attribute") + return + } + + if class, err = g.identityEducationBackend.CreateEducationClass(r.Context(), *class); err != nil { + logger.Debug().Interface("class", class).Msg("could not create class: backend error") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + + /* TODO requires reva changes + if class != nil && class.Id != nil { + currentUser := revactx.ContextMustGetUser(r.Context()) + g.publishEvent(events.EducationClassCreated{Executant: currentUser.Id, EducationClassID: *class.Id}) + } + */ + render.Status(r, http.StatusCreated) + render.JSON(w, r, class) +} + +// PatchEducationClass implements the Service interface. +func (g Graph) PatchEducationClass(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling patch education class") + classID := chi.URLParam(r, "classID") + classID, err := url.PathUnescape(classID) + if err != nil { + logger.Debug().Str("id", classID).Msg("could not change class: unescaping class id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed") + return + } + + if classID == "" { + logger.Debug().Msg("could not change class: missing class id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id") + return + } + changes := libregraph.NewEducationClassWithDefaults() + err = json.NewDecoder(r.Body).Decode(changes) + if err != nil { + logger.Debug().Err(err).Interface("body", r.Body).Msg("could not change class: invalid request body") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error())) + return + } + + if memberRefs, ok := changes.GetMembersodataBindOk(); ok { + // The spec defines a limit of 20 members maxium per Request + if len(memberRefs) > g.config.API.GroupMembersPatchLimit { + logger.Debug(). + Int("number", len(memberRefs)). + Int("limit", g.config.API.GroupMembersPatchLimit). + Msg("could not add group members, exceeded members limit") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, + fmt.Sprintf("Request is limited to %d members", g.config.API.GroupMembersPatchLimit)) + return + } + memberIDs := make([]string, 0, len(memberRefs)) + for _, memberRef := range memberRefs { + memberType, id, err := g.parseMemberRef(memberRef) + if err != nil { + logger.Debug(). + Str("memberref", memberRef). + Msg("could not change class: Error parsing member@odata.bind values") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing member@odata.bind values") + return + } + logger.Debug().Str("membertype", memberType).Str("memberid", id).Msg("add class member") + // The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact" + // we restrict this to users for now. Might add Classes as members later + if memberType != memberTypeUsers { + logger.Debug(). + Str("type", memberType). + Msg("could not change class: could not add member, only user type is allowed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only user are allowed as class members") + return + } + memberIDs = append(memberIDs, id) + } + err = g.identityBackend.AddMembersToGroup(r.Context(), classID, memberIDs) + } + + if err != nil { + logger.Debug().Err(err).Msg("could not change class: backend could not add members") + var errcode errorcode.Error + if errors.As(err, &errcode) { + errcode.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + } + return + } + render.Status(r, http.StatusNoContent) // TODO StatusNoContent when prefer=minimal is used, otherwise OK and the resource in the body + render.NoContent(w, r) +} + +// GetEducationClass implements the Service interface. +func (g Graph) GetEducationClass(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling get education class") + classID := chi.URLParam(r, "classID") + classID, err := url.PathUnescape(classID) + if err != nil { + logger.Debug().Str("id", classID).Msg("could not get class: unescaping class id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed") + } + + if classID == "" { + logger.Debug().Msg("could not get class: missing class id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id") + return + } + + logger.Debug(). + Str("id", classID). + Interface("query", r.URL.Query()). + Msg("calling get class on backend") + class, err := g.identityEducationBackend.GetEducationClass(r.Context(), classID, r.URL.Query()) + if err != nil { + logger.Debug().Err(err).Msg("could not get class: backend error") + var errcode errorcode.Error + if errors.As(err, &errcode) { + errcode.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + } + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, class) +} + +// DeleteEducationClass implements the Service interface. +func (g Graph) DeleteEducationClass(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling delete class") + classID := chi.URLParam(r, "classID") + classID, err := url.PathUnescape(classID) + if err != nil { + logger.Debug().Err(err).Str("id", classID).Msg("could not delete class: unescaping class id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed") + return + } + + if classID == "" { + logger.Debug().Msg("could not delete class: missing class id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id") + return + } + + logger.Debug().Str("id", classID).Msg("calling delete class on backend") + err = g.identityEducationBackend.DeleteEducationClass(r.Context(), classID) + + if err != nil { + logger.Debug().Err(err).Msg("could not delete class: backend error") + var errcode errorcode.Error + if errors.As(err, &errcode) { + errcode.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + } + return + } + + /* TODO requires reva changes + currentUser := revactx.ContextMustGetUser(r.Context()) + g.publishEvent(events.ClassDeleted{Executant: currentUser.Id, ClassID: classID}) + */ + + render.Status(r, http.StatusNoContent) + render.NoContent(w, r) +} + +// GetEducationClassMembers implements the Service interface. +func (g Graph) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling get class members") + classID := chi.URLParam(r, "classID") + classID, err := url.PathUnescape(classID) + if err != nil { + logger.Debug().Str("id", classID).Msg("could not get class members: unescaping class id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed") + return + } + + if classID == "" { + logger.Debug().Msg("could not get class members: missing class id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id") + return + } + + logger.Debug().Str("id", classID).Msg("calling get class members on backend") + members, err := g.identityEducationBackend.GetEducationClassMembers(r.Context(), classID) + if err != nil { + logger.Debug().Err(err).Msg("could not get class members: backend error") + var errcode errorcode.Error + if errors.As(err, &errcode) { + errcode.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + } + return + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, members) +} + +// PostEducationClassMember implements the Service interface. +func (g Graph) PostEducationClassMember(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("Calling post class member") + + classID := chi.URLParam(r, "classID") + classID, err := url.PathUnescape(classID) + if err != nil { + logger.Debug(). + Err(err). + Str("id", classID). + Msg("could not add member to class: unescaping class id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed") + return + } + + if classID == "" { + logger.Debug().Msg("could not add class member: missing class id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id") + return + } + memberRef := libregraph.NewMemberReference() + err = json.NewDecoder(r.Body).Decode(memberRef) + if err != nil { + logger.Debug(). + Err(err). + Interface("body", r.Body). + Msg("could not add class member: invalid request body") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error())) + return + } + memberRefURL, ok := memberRef.GetOdataIdOk() + if !ok { + logger.Debug().Msg("could not add class member: @odata.id reference is missing") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing") + return + } + memberType, id, err := g.parseMemberRef(*memberRefURL) + if err != nil { + logger.Debug().Err(err).Msg("could not add class member: error parsing @odata.id url") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url") + return + } + // The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact" + // we restrict this to users for now. Might add EducationClass as members later + if memberType != memberTypeUsers { + logger.Debug().Str("type", memberType).Msg("could not add class member: Only users are allowed as class members") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as class members") + return + } + + logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add member on backend") + err = g.identityBackend.AddMembersToGroup(r.Context(), classID, []string{id}) + + if err != nil { + logger.Debug().Err(err).Msg("could not add class member: backend error") + var errcode errorcode.Error + if errors.As(err, &errcode) { + errcode.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + } + return + } + + /* TODO requires reva changes + currentUser := revactx.ContextMustGetUser(r.Context()) + g.publishEvent(events.EducationClassMemberAdded{Executant: currentUser.Id, EducationClassID: classID, UserID: id}) + */ + render.Status(r, http.StatusNoContent) + render.NoContent(w, r) +} + +// DeleteEducationClassMember implements the Service interface. +func (g Graph) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling delete class member") + + classID := chi.URLParam(r, "classID") + classID, err := url.PathUnescape(classID) + if err != nil { + logger.Debug().Err(err).Str("id", classID).Msg("could not delete class member: unescaping class id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed") + return + } + + if classID == "" { + logger.Debug().Msg("could not delete class member: missing class id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id") + return + } + + memberID := chi.URLParam(r, "memberID") + memberID, err = url.PathUnescape(memberID) + if err != nil { + logger.Debug().Err(err).Str("id", memberID).Msg("could not delete class member: unescaping member id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping member id failed") + return + } + + if memberID == "" { + logger.Debug().Msg("could not delete class member: missing member id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id") + return + } + logger.Debug().Str("classID", classID).Str("memberID", memberID).Msg("calling delete member on backend") + err = g.identityBackend.RemoveMemberFromGroup(r.Context(), classID, memberID) + + if err != nil { + logger.Debug().Err(err).Msg("could not delete class member: backend error") + var errcode errorcode.Error + if errors.As(err, &errcode) { + errcode.Render(w, r) + } else { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + } + return + } + /* TODO requires reva changes + currentUser := revactx.ContextMustGetUser(r.Context()) + g.publishEvent(events.EducationClassMemberRemoved{Executant: currentUser.Id, EducationClassID: classID, UserID: memberID}) + */ + render.Status(r, http.StatusNoContent) + render.NoContent(w, r) +} + +func sortClasses(req *godata.GoDataRequest, classes []*libregraph.EducationClass) ([]*libregraph.EducationClass, error) { + if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 { + return classes, nil + } + var less func(i, j int) bool + + switch req.Query.OrderBy.OrderByItems[0].Field.Value { + case displayNameAttr: + less = func(i, j int) bool { + return strings.ToLower(classes[i].GetDisplayName()) < strings.ToLower(classes[j].GetDisplayName()) + } + default: + return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value) + } + + if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending { + sort.Slice(classes, reverse(less)) + } else { + sort.Slice(classes, less) + } + + return classes, nil +} diff --git a/services/graph/pkg/service/v0/educationclasses_test.go b/services/graph/pkg/service/v0/educationclasses_test.go new file mode 100644 index 00000000000..850e1b8976b --- /dev/null +++ b/services/graph/pkg/service/v0/educationclasses_test.go @@ -0,0 +1,530 @@ +package svc_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + revactx "github.com/cs3org/reva/v2/pkg/ctx" + "github.com/go-chi/chi/v5" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/test-go/testify/mock" + + libregraph "github.com/owncloud/libre-graph-api-go" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + ogrpc "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc" + "github.com/owncloud/ocis/v2/ocis-pkg/shared" + "github.com/owncloud/ocis/v2/services/graph/mocks" + "github.com/owncloud/ocis/v2/services/graph/pkg/config" + "github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults" + identitymocks "github.com/owncloud/ocis/v2/services/graph/pkg/identity/mocks" + service "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0" + "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode" +) + +type classList struct { + Value []*libregraph.EducationClass +} + +var _ = Describe("EducationClass", func() { + var ( + svc service.Service + ctx context.Context + cfg *config.Config + gatewayClient *mocks.GatewayClient + eventsPublisher mocks.Publisher + identityBackend *identitymocks.Backend + identityEducationBackend *identitymocks.EducationBackend + + rr *httptest.ResponseRecorder + + newClass *libregraph.EducationClass + currentUser = &userv1beta1.User{ + Id: &userv1beta1.UserId{ + OpaqueId: "user", + }, + } + ) + + BeforeEach(func() { + eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + identityEducationBackend = &identitymocks.EducationBackend{} + identityBackend = &identitymocks.Backend{} + gatewayClient = &mocks.GatewayClient{} + newClass = libregraph.NewEducationClass("math", "course") + newClass.SetMembersodataBind([]string{"/users/user1"}) + newClass.SetId("math") + + rr = httptest.NewRecorder() + ctx = context.Background() + + cfg = defaults.FullDefaultConfig() + cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests + cfg.TokenManager.JWTSecret = "loremipsum" + cfg.Commons = &shared.Commons{} + cfg.GRPCClientTLS = &shared.GRPCClientTLS{} + + _ = ogrpc.Configure(ogrpc.GetClientOptions(cfg.GRPCClientTLS)...) + svc, _ = service.NewService( + service.Config(cfg), + service.WithGatewayClient(gatewayClient), + service.EventsPublisher(&eventsPublisher), + service.WithIdentityBackend(identityBackend), + service.WithIdentityEducationBackend(identityEducationBackend), + service.Logger(log.NewLogger(log.Level("debug"))), + ) + }) + + Describe("GetEducationClasses", func() { + It("handles invalid ODATA parameters", func() { + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes?§foo=bar", nil) + svc.GetEducationClasses(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles invalid sorting queries", func() { + identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{newClass}, nil) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes?$orderby=invalid", nil) + svc.GetEducationClasses(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + odataerr := libregraph.OdataError{} + err = json.Unmarshal(data, &odataerr) + Expect(err).ToNot(HaveOccurred()) + Expect(odataerr.Error.Code).To(Equal("invalidRequest")) + }) + + It("handles unknown backend errors", func() { + identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return(nil, errors.New("failed")) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil) + svc.GetEducationClasses(rr, r) + Expect(rr.Code).To(Equal(http.StatusInternalServerError)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + odataerr := libregraph.OdataError{} + err = json.Unmarshal(data, &odataerr) + Expect(err).ToNot(HaveOccurred()) + Expect(odataerr.Error.Code).To(Equal("generalException")) + }) + + It("handles backend errors", func() { + identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied")) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil) + svc.GetEducationClasses(rr, r) + + Expect(rr.Code).To(Equal(http.StatusInternalServerError)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + odataerr := libregraph.OdataError{} + err = json.Unmarshal(data, &odataerr) + Expect(err).ToNot(HaveOccurred()) + Expect(odataerr.Error.Code).To(Equal("accessDenied")) + }) + + It("renders an empty list of classes", func() { + identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{}, nil) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil) + svc.GetEducationClasses(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + res := service.ListResponse{} + err = json.Unmarshal(data, &res) + Expect(err).ToNot(HaveOccurred()) + Expect(res.Value).To(Equal([]interface{}{})) + }) + + It("renders a list of classes", func() { + identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{newClass}, nil) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil) + svc.GetEducationClasses(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + res := groupList{} + err = json.Unmarshal(data, &res) + Expect(err).ToNot(HaveOccurred()) + + Expect(len(res.Value)).To(Equal(1)) + Expect(res.Value[0].GetId()).To(Equal("math")) + }) + }) + + Describe("GetEducationClass", func() { + It("handles missing or empty class id", func() { + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil) + svc.GetEducationClass(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + + r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", "") + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx)) + svc.GetEducationClass(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + Context("with an existing class", func() { + BeforeEach(func() { + identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil) + }) + + It("gets the class", func() { + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/"+*newClass.Id, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx)) + + svc.GetEducationClass(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + }) + }) + }) + + Describe("PostEducationClass", func() { + It("handles invalid body", func() { + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBufferString("{invalid")) + + svc.PostEducationClass(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles missing display name", func() { + newClass = libregraph.NewEducationClassWithDefaults() + newClass.SetId("disallowed") + newClass.SetMembersodataBind([]string{"/non-users/user"}) + newClassJson, err := json.Marshal(newClass) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBuffer(newClassJson)) + + svc.PostEducationClass(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("disallows group create ids", func() { + newClass = libregraph.NewEducationClassWithDefaults() + newClass.SetId("disallowed") + newClass.SetDisplayName("New Class") + newClass.SetMembersodataBind([]string{"/non-users/user"}) + newClassJson, err := json.Marshal(newClass) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBuffer(newClassJson)) + + svc.PostEducationClass(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("creates the Class", func() { + newClass = libregraph.NewEducationClassWithDefaults() + newClass.SetDisplayName("New Class") + newClassJson, err := json.Marshal(newClass) + Expect(err).ToNot(HaveOccurred()) + + identityEducationBackend.On("CreateEducationClass", mock.Anything, mock.Anything).Return(newClass, nil) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/class/", bytes.NewBuffer(newClassJson)) + + svc.PostEducationClass(rr, r) + + Expect(rr.Code).To(Equal(http.StatusCreated)) + }) + }) + + Describe("PatchEducationClass", func() { + It("handles invalid body", func() { + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes/", bytes.NewBufferString("{invalid")) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchEducationClass(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles missing or empty group id", func() { + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil) + svc.PatchEducationClass(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + + r = httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", "") + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchEducationClass(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + Context("with an existing group", func() { + BeforeEach(func() { + identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil) + }) + + It("fails when the number of users is exceeded - spec says 20 max", func() { + updatedClass := libregraph.NewEducationClassWithDefaults() + updatedClass.SetDisplayName("class updated") + updatedClass.SetMembersodataBind([]string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", + "19", "20", "21"}) + updatedClassJson, err := json.Marshal(updatedClass) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchEducationClass(rr, r) + + resp, err := ioutil.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + Expect(string(resp)).To(ContainSubstring("Request is limited to 20")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("succeeds when the number of users is over 20 but the limit is raised to 21", func() { + updatedClass := libregraph.NewEducationClassWithDefaults() + updatedClass.SetDisplayName("group1 updated") + updatedClass.SetMembersodataBind([]string{ + "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", + "19", "20", "21", + }) + updatedClassJson, err := json.Marshal(updatedClass) + Expect(err).ToNot(HaveOccurred()) + + cfg.API.GroupMembersPatchLimit = 21 + svc, _ = service.NewService( + service.Config(cfg), + service.WithGatewayClient(gatewayClient), + service.EventsPublisher(&eventsPublisher), + service.WithIdentityBackend(identityBackend), + ) + + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchEducationClass(rr, r) + + resp, err := ioutil.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + Expect(string(resp)).To(ContainSubstring("Error parsing member@odata.bind values")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("fails on invalid user refs", func() { + updatedClass := libregraph.NewEducationClassWithDefaults() + updatedClass.SetDisplayName("class updated") + updatedClass.SetMembersodataBind([]string{"invalid"}) + updatedClassJson, err := json.Marshal(updatedClass) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchEducationClass(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("fails when the adding non-users users", func() { + updatedClass := libregraph.NewEducationClassWithDefaults() + updatedClass.SetDisplayName("group1 updated") + updatedClass.SetMembersodataBind([]string{"/non-users/user1"}) + updatedClassJson, err := json.Marshal(updatedClass) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchEducationClass(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("adds members to the class", func() { + identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + updatedClass := libregraph.NewEducationClassWithDefaults() + updatedClass.SetDisplayName("Class updated") + updatedClass.SetMembersodataBind([]string{"/users/user1"}) + updatedClassJson, err := json.Marshal(updatedClass) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchEducationClass(rr, r) + + Expect(rr.Code).To(Equal(http.StatusNoContent)) + identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1) + }) + }) + }) + + Describe("DeleteEducationClass", func() { + Context("with an existing EducationClass", func() { + BeforeEach(func() { + identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil) + }) + }) + + It("deletes the EducationClass", func() { + identityEducationBackend.On("DeleteEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(nil) + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.DeleteEducationClass(rr, r) + + Expect(rr.Code).To(Equal(http.StatusNoContent)) + identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationClass", 1) + //eventsPublisher.AssertNumberOfCalls(GinkgoT(), "Publish", 1) + }) + }) + + Describe("GetEducationClassMembers", func() { + It("gets the list of members", func() { + user := libregraph.NewEducationUser() + user.SetId("user") + identityEducationBackend.On("GetEducationClassMembers", mock.Anything, mock.Anything, mock.Anything). + Return([]*libregraph.EducationUser{user}, nil) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/{classID}/members", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.GetEducationClassMembers(rr, r) + Expect(rr.Code).To(Equal(http.StatusOK)) + + data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + var members []*libregraph.EducationUser + err = json.Unmarshal(data, &members) + Expect(err).ToNot(HaveOccurred()) + + Expect(len(members)).To(Equal(1)) + Expect(members[0].GetId()).To(Equal("user")) + }) + }) + + Describe("PostEducationClassMember", func() { + It("fails on invalid body", func() { + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBufferString("{invalid")) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PostEducationClassMember(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("fails on missing member refs", func() { + member := libregraph.NewMemberReference() + data, err := json.Marshal(member) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PostEducationClassMember(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("fails on invalid member refs", func() { + member := libregraph.NewMemberReference() + member.SetOdataId("/invalidtype/user") + data, err := json.Marshal(member) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PostEducationClassMember(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("adds a new member", func() { + member := libregraph.NewMemberReference() + member.SetOdataId("/users/user") + data, err := json.Marshal(member) + Expect(err).ToNot(HaveOccurred()) + identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PostEducationClassMember(rr, r) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + + identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1) + }) + }) + + Describe("DeleteEducationClassMembers", func() { + It("handles missing or empty member id", func() { + r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.DeleteEducationClassMember(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles missing or empty member id", func() { + r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("memberID", "/users/user") + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.DeleteEducationClassMember(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("deletes members", func() { + identityBackend.On("RemoveMemberFromGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("classID", *newClass.Id) + rctx.URLParams.Add("memberID", "/users/user1") + r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.DeleteEducationClassMember(rr, r) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + + identityBackend.AssertNumberOfCalls(GinkgoT(), "RemoveMemberFromGroup", 1) + }) + }) +}) diff --git a/services/graph/pkg/service/v0/groups.go b/services/graph/pkg/service/v0/groups.go index 283676b9503..90ee6c18931 100644 --- a/services/graph/pkg/service/v0/groups.go +++ b/services/graph/pkg/service/v0/groups.go @@ -127,7 +127,7 @@ func (g Graph) PatchGroup(w http.ResponseWriter, r *http.Request) { logger.Debug(). Int("number", len(memberRefs)). Int("limit", g.config.API.GroupMembersPatchLimit). - Msg("could not create group, exceeded members limit") + Msg("could not add group members, exceeded members limit") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("Request is limited to %d members", g.config.API.GroupMembersPatchLimit)) return diff --git a/services/graph/pkg/service/v0/instrument.go b/services/graph/pkg/service/v0/instrument.go index eafb980fef9..1c629bab88c 100644 --- a/services/graph/pkg/service/v0/instrument.go +++ b/services/graph/pkg/service/v0/instrument.go @@ -139,6 +139,46 @@ func (i instrument) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Req i.next.DeleteEducationSchoolUser(w, r) } +// GetEducationClasses implements the Service interface. +func (i instrument) GetEducationClasses(w http.ResponseWriter, r *http.Request) { + i.next.GetEducationClasses(w, r) +} + +// GetEducationClass implements the Service interface. +func (i instrument) GetEducationClass(w http.ResponseWriter, r *http.Request) { + i.next.GetEducationClass(w, r) +} + +// PostEducationClass implements the Service interface. +func (i instrument) PostEducationClass(w http.ResponseWriter, r *http.Request) { + i.next.PostEducationClass(w, r) +} + +// PatchEducationClass implements the Service interface. +func (i instrument) PatchEducationClass(w http.ResponseWriter, r *http.Request) { + i.next.PatchEducationClass(w, r) +} + +// DeleteEducationClass implements the Service interface. +func (i instrument) DeleteEducationClass(w http.ResponseWriter, r *http.Request) { + i.next.DeleteEducationClass(w, r) +} + +// GetEducationClassMembers implements the Service interface. +func (i instrument) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) { + i.next.GetEducationClassMembers(w, r) +} + +// PostEducationClassMember implements the Service interface. +func (i instrument) PostEducationClassMember(w http.ResponseWriter, r *http.Request) { + i.next.PostEducationClassMember(w, r) +} + +// DeleteEducationClassMember implements the Service interface. +func (i instrument) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) { + i.next.DeleteEducationClassMember(w, r) +} + // GetEducationUsers implements the Service interface. func (i instrument) GetEducationUsers(w http.ResponseWriter, r *http.Request) { i.next.GetEducationUsers(w, r) diff --git a/services/graph/pkg/service/v0/logging.go b/services/graph/pkg/service/v0/logging.go index 1e3369b6ce1..715739b8772 100644 --- a/services/graph/pkg/service/v0/logging.go +++ b/services/graph/pkg/service/v0/logging.go @@ -139,6 +139,46 @@ func (l logging) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Reques l.next.DeleteEducationSchoolUser(w, r) } +// GetEducationClasses implements the Service interface. +func (l logging) GetEducationClasses(w http.ResponseWriter, r *http.Request) { + l.next.GetEducationClasses(w, r) +} + +// GetEducationClass implements the Service interface. +func (l logging) GetEducationClass(w http.ResponseWriter, r *http.Request) { + l.next.GetEducationClass(w, r) +} + +// PostEducationClass implements the Service interface. +func (l logging) PostEducationClass(w http.ResponseWriter, r *http.Request) { + l.next.PostEducationClass(w, r) +} + +// PatchEducationClass implements the Service interface. +func (l logging) PatchEducationClass(w http.ResponseWriter, r *http.Request) { + l.next.PatchEducationClass(w, r) +} + +// DeleteEducationClass implements the Service interface. +func (l logging) DeleteEducationClass(w http.ResponseWriter, r *http.Request) { + l.next.DeleteEducationClass(w, r) +} + +// GetEducationClassMembers implements the Service interface. +func (l logging) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) { + l.next.GetEducationClassMembers(w, r) +} + +// PostEducationClassMember implements the Service interface. +func (l logging) PostEducationClassMember(w http.ResponseWriter, r *http.Request) { + l.next.PostEducationClassMember(w, r) +} + +// DeleteEducationClassMember implements the Service interface. +func (l logging) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) { + l.next.DeleteEducationClassMember(w, r) +} + // GetEducationUsers implements the Service interface. func (l logging) GetEducationUsers(w http.ResponseWriter, r *http.Request) { l.next.GetEducationUsers(w, r) diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index 98ce2d1a27a..fedaf6351e8 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -58,11 +58,20 @@ type Service interface { PostEducationSchoolUser(http.ResponseWriter, *http.Request) DeleteEducationSchoolUser(http.ResponseWriter, *http.Request) + GetEducationClasses(http.ResponseWriter, *http.Request) + GetEducationClass(http.ResponseWriter, *http.Request) + PostEducationClass(http.ResponseWriter, *http.Request) + PatchEducationClass(http.ResponseWriter, *http.Request) + DeleteEducationClass(w http.ResponseWriter, r *http.Request) + GetEducationClassMembers(w http.ResponseWriter, r *http.Request) + PostEducationClassMember(w http.ResponseWriter, r *http.Request) + GetEducationUsers(http.ResponseWriter, *http.Request) GetEducationUser(http.ResponseWriter, *http.Request) PostEducationUser(http.ResponseWriter, *http.Request) DeleteEducationUser(http.ResponseWriter, *http.Request) PatchEducationUser(http.ResponseWriter, *http.Request) + DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) GetDrives(w http.ResponseWriter, r *http.Request) GetSingleDrive(w http.ResponseWriter, r *http.Request) @@ -293,16 +302,16 @@ func NewService(opts ...Option) (Graph, error) { }) }) r.Route("/classes", func(r chi.Router) { - r.Get("/", svc.GetGroups) - r.Post("/", svc.PostGroup) - r.Route("/{groupID}", func(r chi.Router) { - r.Get("/", svc.GetGroup) - r.Delete("/", svc.DeleteGroup) - r.Patch("/", svc.PatchGroup) + r.Get("/", svc.GetEducationClasses) + r.Post("/", svc.PostEducationClass) + r.Route("/{classID}", func(r chi.Router) { + r.Get("/", svc.GetEducationClass) + r.Delete("/", svc.DeleteEducationClass) + r.Patch("/", svc.PatchEducationClass) r.Route("/members", func(r chi.Router) { - r.Get("/", svc.GetGroupMembers) - r.Post("/$ref", svc.PostGroupMember) - r.Delete("/{memberID}/$ref", svc.DeleteGroupMember) + r.Get("/", svc.GetEducationClassMembers) + r.Post("/$ref", svc.PostEducationClassMember) + r.Delete("/{memberID}/$ref", svc.DeleteEducationClassMember) }) }) }) diff --git a/services/graph/pkg/service/v0/tracing.go b/services/graph/pkg/service/v0/tracing.go index 160289f7416..691df5f11e7 100644 --- a/services/graph/pkg/service/v0/tracing.go +++ b/services/graph/pkg/service/v0/tracing.go @@ -135,6 +135,46 @@ func (t tracing) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Reques t.next.DeleteEducationSchoolUser(w, r) } +// GetEducationClasses implements the Service interface. +func (t tracing) GetEducationClasses(w http.ResponseWriter, r *http.Request) { + t.next.GetEducationClasses(w, r) +} + +// GetEducationClass implements the Service interface. +func (t tracing) GetEducationClass(w http.ResponseWriter, r *http.Request) { + t.next.GetEducationClass(w, r) +} + +// PostEducationClass implements the Service interface. +func (t tracing) PostEducationClass(w http.ResponseWriter, r *http.Request) { + t.next.PostEducationClass(w, r) +} + +// PatchEducationClass implements the Service interface. +func (t tracing) PatchEducationClass(w http.ResponseWriter, r *http.Request) { + t.next.PatchEducationClass(w, r) +} + +// DeleteEducationClass implements the Service interface. +func (t tracing) DeleteEducationClass(w http.ResponseWriter, r *http.Request) { + t.next.DeleteEducationClass(w, r) +} + +// GetEducationClassMembers implements the Service interface. +func (t tracing) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) { + t.next.GetEducationClassMembers(w, r) +} + +// PostEducationClassMember implements the Service interface. +func (t tracing) PostEducationClassMember(w http.ResponseWriter, r *http.Request) { + t.next.PostEducationClassMember(w, r) +} + +// DeleteEducationClassMember implements the Service interface. +func (t tracing) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) { + t.next.DeleteEducationClassMember(w, r) +} + // GetEducationUsers implements the Service interface. func (t tracing) GetEducationUsers(w http.ResponseWriter, r *http.Request) { t.next.GetEducationUsers(w, r)