From 9844f5f8ce4cf77b7a181d68b663473e7134672f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Wed, 7 Dec 2022 15:49:57 +0000 Subject: [PATCH] initial schools API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jörn Friedrich Dreyer --- services/graph/Makefile | 1 + services/graph/pkg/identity/backend.go | 18 + services/graph/pkg/identity/ldap_school.go | 65 +++ .../pkg/identity/mocks/education_backend.go | 167 +++++++ services/graph/pkg/service/v0/graph.go | 41 +- services/graph/pkg/service/v0/groups.go | 19 +- services/graph/pkg/service/v0/instrument.go | 40 ++ services/graph/pkg/service/v0/logging.go | 40 ++ services/graph/pkg/service/v0/option.go | 30 +- services/graph/pkg/service/v0/ordering.go | 18 + services/graph/pkg/service/v0/schools.go | 397 +++++++++++++++ services/graph/pkg/service/v0/schools_test.go | 465 ++++++++++++++++++ services/graph/pkg/service/v0/service.go | 70 ++- services/graph/pkg/service/v0/tracing.go | 40 ++ 14 files changed, 1365 insertions(+), 46 deletions(-) create mode 100644 services/graph/pkg/identity/ldap_school.go create mode 100644 services/graph/pkg/identity/mocks/education_backend.go create mode 100644 services/graph/pkg/service/v0/schools.go create mode 100644 services/graph/pkg/service/v0/schools_test.go diff --git a/services/graph/Makefile b/services/graph/Makefile index 692f9ede17..382f03f125 100644 --- a/services/graph/Makefile +++ b/services/graph/Makefile @@ -31,6 +31,7 @@ ci-go-generate: $(MOCKERY) # CI runs ci-node-generate automatically before this $(MOCKERY) --dir pkg/service/v0 --case underscore --name Permissions $(MOCKERY) --dir pkg/service/v0 --case underscore --name RoleService $(MOCKERY) --dir pkg/identity --output pkg/identity/mocks --case underscore --name Backend + $(MOCKERY) --dir pkg/identity --output pkg/identity/mocks --case underscore --name EducationBackend $(MOCKERY) --srcpkg github.com/go-ldap/ldap/v3 --case underscore --filename ldapclient.go --name Client diff --git a/services/graph/pkg/identity/backend.go b/services/graph/pkg/identity/backend.go index db003922e2..4d22ed27ba 100644 --- a/services/graph/pkg/identity/backend.go +++ b/services/graph/pkg/identity/backend.go @@ -8,6 +8,7 @@ import ( libregraph "github.com/owncloud/libre-graph-api-go" ) +// Backend defines the Interface for an IdentityBackend implementation type Backend interface { // CreateUser creates a given user in the identity backend. CreateUser(ctx context.Context, user libregraph.User) (*libregraph.User, error) @@ -31,6 +32,23 @@ type Backend interface { RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error } +// EducationBackend defines the Interface for an EducationBackend implementation +type EducationBackend interface { + // CreateSchool creates the supplied school in the identity backend. + CreateSchool(ctx context.Context, group libregraph.EducationSchool) (*libregraph.EducationSchool, error) + // DeleteSchool deletes a given school, identified by id + DeleteSchool(ctx context.Context, id string) error + // GetSchool reads a given school by id + GetSchool(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationSchool, error) + // GetSchools lists all schools + GetSchools(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationSchool, error) + GetSchoolMembers(ctx context.Context, id string) ([]*libregraph.User, error) + // AddMembersToSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend. + AddMembersToSchool(ctx context.Context, schoolID string, memberID []string) error + // RemoveMemberFromSchool removes a single member (by ID) from a school + RemoveMemberFromSchool(ctx context.Context, schoolID string, memberID string) error +} + func CreateUserModelFromCS3(u *cs3.User) *libregraph.User { if u.Id == nil { u.Id = &cs3.UserId{} diff --git a/services/graph/pkg/identity/ldap_school.go b/services/graph/pkg/identity/ldap_school.go new file mode 100644 index 0000000000..3be98f069a --- /dev/null +++ b/services/graph/pkg/identity/ldap_school.go @@ -0,0 +1,65 @@ +package identity + +import ( + "context" + "net/url" + + libregraph "github.com/owncloud/libre-graph-api-go" +) + +type schoolConfig struct { + schoolBaseDN string + schoolFilter string + schoolObjectClass string + schoolScope int + schoolAttributeMap schoolAttributeMap +} + +type schoolAttributeMap struct { + displayname string + schoolNumber string + id string +} + +func newSchoolAttributeMap() schoolAttributeMap { + return schoolAttributeMap{ + displayname: "ou", + schoolNumber: "ocEducationSchoolNumber", + id: "owncloudUUID", + } +} + +// CreateSchool creates the supplied school in the identity backend. +func (i *LDAP) CreateSchool(ctx context.Context, school libregraph.EducationSchool) (*libregraph.EducationSchool, error) { + return nil, errNotImplemented +} + +// DeleteSchool deletes a given school, identified by id +func (i *LDAP) DeleteSchool(ctx context.Context, id string) error { + return errNotImplemented +} + +// GetSchool implements the EducationBackend interface for the LDAP backend. +func (i *LDAP) GetSchool(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationSchool, error) { + return nil, errNotImplemented +} + +// GetSchools implements the EducationBackend interface for the LDAP backend. +func (i *LDAP) GetSchools(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationSchool, error) { + return nil, errNotImplemented +} + +// GetSchoolMembers implements the EducationBackend interface for the LDAP backend. +func (i *LDAP) GetSchoolMembers(ctx context.Context, id string) ([]*libregraph.User, error) { + return nil, errNotImplemented +} + +// AddMembersToSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend. +func (i *LDAP) AddMembersToSchool(ctx context.Context, schoolID string, memberID []string) error { + return errNotImplemented +} + +// RemoveMemberFromSchool removes a single member (by ID) from a school +func (i *LDAP) RemoveMemberFromSchool(ctx context.Context, schoolID string, memberID string) error { + return errNotImplemented +} diff --git a/services/graph/pkg/identity/mocks/education_backend.go b/services/graph/pkg/identity/mocks/education_backend.go new file mode 100644 index 0000000000..2d4ff4bf64 --- /dev/null +++ b/services/graph/pkg/identity/mocks/education_backend.go @@ -0,0 +1,167 @@ +// Code generated by mockery v2.14.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + libregraph "github.com/owncloud/libre-graph-api-go" + + mock "github.com/stretchr/testify/mock" + + url "net/url" +) + +// EducationBackend is an autogenerated mock type for the EducationBackend type +type EducationBackend struct { + mock.Mock +} + +// AddMembersToSchool provides a mock function with given fields: ctx, schoolID, memberID +func (_m *EducationBackend) AddMembersToSchool(ctx context.Context, schoolID string, memberID []string) error { + ret := _m.Called(ctx, schoolID, memberID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, []string) error); ok { + r0 = rf(ctx, schoolID, memberID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// CreateSchool provides a mock function with given fields: ctx, group +func (_m *EducationBackend) CreateSchool(ctx context.Context, group libregraph.EducationSchool) (*libregraph.EducationSchool, error) { + ret := _m.Called(ctx, group) + + var r0 *libregraph.EducationSchool + if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationSchool) *libregraph.EducationSchool); ok { + r0 = rf(ctx, group) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*libregraph.EducationSchool) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, libregraph.EducationSchool) error); ok { + r1 = rf(ctx, group) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DeleteSchool provides a mock function with given fields: ctx, id +func (_m *EducationBackend) DeleteSchool(ctx context.Context, id string) error { + ret := _m.Called(ctx, id) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetSchool provides a mock function with given fields: ctx, nameOrID, queryParam +func (_m *EducationBackend) GetSchool(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationSchool, error) { + ret := _m.Called(ctx, nameOrID, queryParam) + + var r0 *libregraph.EducationSchool + if rf, ok := ret.Get(0).(func(context.Context, string, url.Values) *libregraph.EducationSchool); ok { + r0 = rf(ctx, nameOrID, queryParam) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*libregraph.EducationSchool) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, url.Values) error); ok { + r1 = rf(ctx, nameOrID, queryParam) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetSchoolMembers provides a mock function with given fields: ctx, id +func (_m *EducationBackend) GetSchoolMembers(ctx context.Context, id string) ([]*libregraph.User, error) { + ret := _m.Called(ctx, id) + + var r0 []*libregraph.User + if rf, ok := ret.Get(0).(func(context.Context, string) []*libregraph.User); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*libregraph.User) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetSchools provides a mock function with given fields: ctx, queryParam +func (_m *EducationBackend) GetSchools(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationSchool, error) { + ret := _m.Called(ctx, queryParam) + + var r0 []*libregraph.EducationSchool + if rf, ok := ret.Get(0).(func(context.Context, url.Values) []*libregraph.EducationSchool); ok { + r0 = rf(ctx, queryParam) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*libregraph.EducationSchool) + } + } + + 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 +} + +// RemoveMemberFromSchool provides a mock function with given fields: ctx, schoolID, memberID +func (_m *EducationBackend) RemoveMemberFromSchool(ctx context.Context, schoolID string, memberID string) error { + ret := _m.Called(ctx, schoolID, memberID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = rf(ctx, schoolID, memberID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTNewEducationBackend interface { + mock.TestingT + Cleanup(func()) +} + +// NewEducationBackend creates a new instance of EducationBackend. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewEducationBackend(t mockConstructorTestingTNewEducationBackend) *EducationBackend { + mock := &EducationBackend{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/services/graph/pkg/service/v0/graph.go b/services/graph/pkg/service/v0/graph.go index 3ee2af8986..625be86e55 100644 --- a/services/graph/pkg/service/v0/graph.go +++ b/services/graph/pkg/service/v0/graph.go @@ -2,9 +2,11 @@ package svc import ( "context" + "errors" "net/http" "net/url" "path" + "strings" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" @@ -90,16 +92,17 @@ type RoleService interface { // Graph defines implements the business logic for Service. type Graph struct { - config *config.Config - mux *chi.Mux - logger *log.Logger - identityBackend identity.Backend - gatewayClient GatewayClient - roleService RoleService - permissionsService Permissions - spacePropertiesCache *ttlcache.Cache - eventsPublisher events.Publisher - searchService searchsvc.SearchProviderService + config *config.Config + mux *chi.Mux + logger *log.Logger + identityBackend identity.Backend + identityEducationBackend identity.EducationBackend + gatewayClient GatewayClient + roleService RoleService + permissionsService Permissions + spacePropertiesCache *ttlcache.Cache + eventsPublisher events.Publisher + searchService searchsvc.SearchProviderService } // ServeHTTP implements the Service interface. @@ -107,7 +110,7 @@ func (g Graph) ServeHTTP(w http.ResponseWriter, r *http.Request) { g.mux.ServeHTTP(w, r) } -// GetClient returns a gateway client to talk to reva +// GetGatewayClient returns a gateway client to talk to reva func (g Graph) GetGatewayClient() GatewayClient { return g.gatewayClient } @@ -131,6 +134,7 @@ func (g Graph) getWebDavBaseURL() (*url.URL, error) { return webDavBaseURL, nil } +// ListResponse is used for proper marshalling of Graph list responses type ListResponse struct { Value interface{} `json:"value,omitempty"` } @@ -139,3 +143,18 @@ const ( ReadmeSpecialFolderName = "readme" SpaceImageSpecialFolderName = "image" ) + +// TODO might be different for /education/users vs /users +func (g Graph) parseMemberRef(ref string) (string, string, error) { + memberURL, err := url.ParseRequestURI(ref) + if err != nil { + return "", "", err + } + segments := strings.Split(memberURL.Path, "/") + if len(segments) < 2 { + return "", "", errors.New("invalid member reference") + } + id := segments[len(segments)-1] + memberType := segments[len(segments)-2] + return memberType, id, nil +} diff --git a/services/graph/pkg/service/v0/groups.go b/services/graph/pkg/service/v0/groups.go index ce610496c4..d0977ffb47 100644 --- a/services/graph/pkg/service/v0/groups.go +++ b/services/graph/pkg/service/v0/groups.go @@ -20,6 +20,7 @@ import ( ) const memberRefsLimit = 20 +const memberTypeUsers = "users" // GetGroups implements the Service interface. func (g Graph) GetGroups(w http.ResponseWriter, r *http.Request) { @@ -145,7 +146,7 @@ func (g Graph) PatchGroup(w http.ResponseWriter, r *http.Request) { logger.Debug().Str("membertype", memberType).Str("memberid", id).Msg("add group member") // The MS Graph spec allows "directoryObject", "user", "group" and "organizational Contact" // we restrict this to users for now. Might add Groups as members later - if memberType != "users" { + if memberType != memberTypeUsers { logger.Debug(). Str("type", memberType). Msg("could not change group: could not add member, only user type is allowed") @@ -324,7 +325,7 @@ func (g Graph) PostGroupMember(w http.ResponseWriter, r *http.Request) { } // The MS Graph spec allows "directoryObject", "user", "group" and "organizational Contact" // we restrict this to users for now. Might add Groups as members later - if memberType != "users" { + if memberType != memberTypeUsers { logger.Debug().Str("type", memberType).Msg("could not add group member: Only users are allowed as group members") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as group members") return @@ -401,20 +402,6 @@ func (g Graph) DeleteGroupMember(w http.ResponseWriter, r *http.Request) { render.NoContent(w, r) } -func (g Graph) parseMemberRef(ref string) (string, string, error) { - memberURL, err := url.ParseRequestURI(ref) - if err != nil { - return "", "", err - } - segments := strings.Split(memberURL.Path, "/") - if len(segments) < 2 { - return "", "", errors.New("invalid member reference") - } - id := segments[len(segments)-1] - memberType := segments[len(segments)-2] - return memberType, id, nil -} - func sortGroups(req *godata.GoDataRequest, groups []*libregraph.Group) ([]*libregraph.Group, error) { var sorter sort.Interface if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 { diff --git a/services/graph/pkg/service/v0/instrument.go b/services/graph/pkg/service/v0/instrument.go index 42d5730750..12aac9dd3f 100644 --- a/services/graph/pkg/service/v0/instrument.go +++ b/services/graph/pkg/service/v0/instrument.go @@ -99,6 +99,46 @@ func (i instrument) DeleteGroupMember(w http.ResponseWriter, r *http.Request) { i.next.DeleteGroupMember(w, r) } +// GetSchools implements the Service interface. +func (i instrument) GetSchools(w http.ResponseWriter, r *http.Request) { + i.next.GetSchools(w, r) +} + +// GetSchool implements the Service interface. +func (i instrument) GetSchool(w http.ResponseWriter, r *http.Request) { + i.next.GetSchool(w, r) +} + +// PostSchool implements the Service interface. +func (i instrument) PostSchool(w http.ResponseWriter, r *http.Request) { + i.next.PostSchool(w, r) +} + +// PatchSchool implements the Service interface. +func (i instrument) PatchSchool(w http.ResponseWriter, r *http.Request) { + i.next.PatchSchool(w, r) +} + +// DeleteSchool implements the Service interface. +func (i instrument) DeleteSchool(w http.ResponseWriter, r *http.Request) { + i.next.DeleteSchool(w, r) +} + +// GetSchoolMembers implements the Service interface. +func (i instrument) GetSchoolMembers(w http.ResponseWriter, r *http.Request) { + i.next.GetSchoolMembers(w, r) +} + +// PostSchoolMember implements the Service interface. +func (i instrument) PostSchoolMember(w http.ResponseWriter, r *http.Request) { + i.next.PostSchoolMember(w, r) +} + +// DeleteSchoolMember implements the Service interface. +func (i instrument) DeleteSchoolMember(w http.ResponseWriter, r *http.Request) { + i.next.DeleteSchoolMember(w, r) +} + // GetDrives implements the Service interface. func (i instrument) GetDrives(w http.ResponseWriter, r *http.Request) { i.next.GetDrives(w, r) diff --git a/services/graph/pkg/service/v0/logging.go b/services/graph/pkg/service/v0/logging.go index 316bbd55b8..cdd39a5abc 100644 --- a/services/graph/pkg/service/v0/logging.go +++ b/services/graph/pkg/service/v0/logging.go @@ -99,6 +99,46 @@ func (l logging) DeleteGroupMember(w http.ResponseWriter, r *http.Request) { l.next.DeleteGroupMember(w, r) } +// GetSchools implements the Service interface. +func (l logging) GetSchools(w http.ResponseWriter, r *http.Request) { + l.next.GetSchools(w, r) +} + +// GetSchool implements the Service interface. +func (l logging) GetSchool(w http.ResponseWriter, r *http.Request) { + l.next.GetSchool(w, r) +} + +// PostSchool implements the Service interface. +func (l logging) PostSchool(w http.ResponseWriter, r *http.Request) { + l.next.PostSchool(w, r) +} + +// PatchSchool implements the Service interface. +func (l logging) PatchSchool(w http.ResponseWriter, r *http.Request) { + l.next.PatchSchool(w, r) +} + +// DeleteSchool implements the Service interface. +func (l logging) DeleteSchool(w http.ResponseWriter, r *http.Request) { + l.next.DeleteSchool(w, r) +} + +// GetSchoolMembers implements the Service interface. +func (l logging) GetSchoolMembers(w http.ResponseWriter, r *http.Request) { + l.next.GetSchoolMembers(w, r) +} + +// PostSchoolMember implements the Service interface. +func (l logging) PostSchoolMember(w http.ResponseWriter, r *http.Request) { + l.next.PostSchoolMember(w, r) +} + +// DeleteSchoolMember implements the Service interface. +func (l logging) DeleteSchoolMember(w http.ResponseWriter, r *http.Request) { + l.next.DeleteSchoolMember(w, r) +} + // GetDrives implements the Service interface. func (l logging) GetDrives(w http.ResponseWriter, r *http.Request) { l.next.GetDrives(w, r) diff --git a/services/graph/pkg/service/v0/option.go b/services/graph/pkg/service/v0/option.go index 4ac405b789..ac8f74e4f6 100644 --- a/services/graph/pkg/service/v0/option.go +++ b/services/graph/pkg/service/v0/option.go @@ -17,17 +17,18 @@ type Option func(o *Options) // Options defines the available options for this package. type Options struct { - Logger log.Logger - Config *config.Config - Middleware []func(http.Handler) http.Handler - RequireAdminMiddleware func(http.Handler) http.Handler - GatewayClient GatewayClient - IdentityBackend identity.Backend - RoleService RoleService - PermissionService Permissions - RoleManager *roles.Manager - EventsPublisher events.Publisher - SearchService searchsvc.SearchProviderService + Logger log.Logger + Config *config.Config + Middleware []func(http.Handler) http.Handler + RequireAdminMiddleware func(http.Handler) http.Handler + GatewayClient GatewayClient + IdentityBackend identity.Backend + IdentityEducationBackend identity.EducationBackend + RoleService RoleService + PermissionService Permissions + RoleManager *roles.Manager + EventsPublisher events.Publisher + SearchService searchsvc.SearchProviderService } // newOptions initializes the available default options. @@ -83,6 +84,13 @@ func WithIdentityBackend(val identity.Backend) Option { } } +// WithIdentityEducationBackend provides a function to set the IdentityEducationBackend option. +func WithIdentityEducationBackend(val identity.EducationBackend) Option { + return func(o *Options) { + o.IdentityEducationBackend = val + } +} + // WithRoleService provides a function to set the RoleService option. func WithRoleService(val RoleService) Option { return func(o *Options) { diff --git a/services/graph/pkg/service/v0/ordering.go b/services/graph/pkg/service/v0/ordering.go index 117356a4ff..46614d7a7f 100644 --- a/services/graph/pkg/service/v0/ordering.go +++ b/services/graph/pkg/service/v0/ordering.go @@ -101,3 +101,21 @@ type groupsByDisplayName struct { func (g groupsByDisplayName) Less(i, j int) bool { return strings.ToLower(g.groupSlice[i].GetDisplayName()) < strings.ToLower(g.groupSlice[j].GetDisplayName()) } + +type schoolSlice []*libregraph.EducationSchool + +// Len is the number of elements in the collection. +func (d schoolSlice) Len() int { return len(d) } + +// Swap swaps the elements with indexes i and j. +func (d schoolSlice) Swap(i, j int) { d[i], d[j] = d[j], d[i] } + +type schoolsByDisplayName struct { + schoolSlice +} + +// Less reports whether the element with index i +// must sort before the element with index j. +func (g schoolsByDisplayName) Less(i, j int) bool { + return strings.ToLower(g.schoolSlice[i].GetDisplayName()) < strings.ToLower(g.schoolSlice[j].GetDisplayName()) +} diff --git a/services/graph/pkg/service/v0/schools.go b/services/graph/pkg/service/v0/schools.go new file mode 100644 index 0000000000..ee4a74b48b --- /dev/null +++ b/services/graph/pkg/service/v0/schools.go @@ -0,0 +1,397 @@ +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" + + ctxpkg "github.com/cs3org/reva/v2/pkg/ctx" + "github.com/cs3org/reva/v2/pkg/events" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +// GetSchools implements the Service interface. +func (g Graph) GetSchools(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Interface("query", r.URL.Query()).Msg("calling get schools") + 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 schools: query error") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } + + schools, err := g.identityEducationBackend.GetSchools(r.Context(), r.URL.Query()) + if err != nil { + logger.Debug().Err(err).Msg("could not get schools: 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 + } + + schools, err = sortSchools(odataReq, schools) + if err != nil { + logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get schools: could not sort schools according to query") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } + render.Status(r, http.StatusOK) + render.JSON(w, r, &ListResponse{Value: schools}) +} + +// PostSchool implements the Service interface. +func (g Graph) PostSchool(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling post school") + school := libregraph.NewEducationSchool() + err := json.NewDecoder(r.Body).Decode(school) + if err != nil { + logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create school: invalid request body") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error())) + return + } + + if _, ok := school.GetDisplayNameOk(); !ok { + logger.Debug().Err(err).Interface("school", school).Msg("could not create school: 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 := school.GetIdOk(); ok { + logger.Debug().Msg("could not create school: id is a read-only attribute") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "school id is a read-only attribute") + return + } + + if school, err = g.identityEducationBackend.CreateSchool(r.Context(), *school); err != nil { + logger.Debug().Interface("school", school).Msg("could not create school: backend error") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + + if school != nil && school.Id != nil { + e := events.SchoolCreated{SchoolID: *school.Id} + if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { + e.Executant = currentUser.GetId() + } + g.publishEvent(e) + } + + render.Status(r, http.StatusCreated) + render.JSON(w, r, school) +} + +// PatchSchool implements the Service interface. +func (g Graph) PatchSchool(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling patch school") + schoolID := chi.URLParam(r, "schoolID") + schoolID, err := url.PathUnescape(schoolID) + if err != nil { + logger.Debug().Str("id", schoolID).Msg("could not change school: unescaping school id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") + return + } + + if schoolID == "" { + logger.Debug().Msg("could not change school: missing school id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") + return + } + changes := libregraph.NewEducationSchool() + err = json.NewDecoder(r.Body).Decode(changes) + if err != nil { + logger.Debug().Err(err).Interface("body", r.Body).Msg("could not change school: invalid request body") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error())) + return + } + + e := events.SchoolFeatureChanged{SchoolID: schoolID} + if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { + e.Executant = currentUser.GetId() + } + g.publishEvent(e) + + render.Status(r, http.StatusNoContent) + render.NoContent(w, r) +} + +// GetSchool implements the Service interface. +func (g Graph) GetSchool(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling get school") + schoolID := chi.URLParam(r, "schoolID") + schoolID, err := url.PathUnescape(schoolID) + if err != nil { + logger.Debug().Str("id", schoolID).Msg("could not get school: unescaping school id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") + } + + if schoolID == "" { + logger.Debug().Msg("could not get school: missing school id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") + return + } + + logger.Debug(). + Str("id", schoolID). + Interface("query", r.URL.Query()). + Msg("calling get school on backend") + school, err := g.identityEducationBackend.GetSchool(r.Context(), schoolID, r.URL.Query()) + if err != nil { + logger.Debug().Err(err).Msg("could not get school: 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, school) +} + +// DeleteSchool implements the Service interface. +func (g Graph) DeleteSchool(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling delete school") + schoolID := chi.URLParam(r, "schoolID") + schoolID, err := url.PathUnescape(schoolID) + if err != nil { + logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school: unescaping school id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") + return + } + + if schoolID == "" { + logger.Debug().Msg("could not delete school: missing school id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") + return + } + + logger.Debug().Str("id", schoolID).Msg("calling delete school on backend") + err = g.identityEducationBackend.DeleteSchool(r.Context(), schoolID) + + if err != nil { + logger.Debug().Err(err).Msg("could not delete school: 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 + } + + e := events.SchoolDeleted{SchoolID: schoolID} + if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { + e.Executant = currentUser.GetId() + } + g.publishEvent(e) + + render.Status(r, http.StatusNoContent) + render.NoContent(w, r) +} + +// GetSchoolMembers implements the Service interface. +func (g Graph) GetSchoolMembers(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling get school members") + schoolID := chi.URLParam(r, "schoolID") + schoolID, err := url.PathUnescape(schoolID) + if err != nil { + logger.Debug().Str("id", schoolID).Msg("could not get school members: unescaping school id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") + return + } + + if schoolID == "" { + logger.Debug().Msg("could not get school members: missing school id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id") + return + } + + logger.Debug().Str("id", schoolID).Msg("calling get school members on backend") + members, err := g.identityEducationBackend.GetSchoolMembers(r.Context(), schoolID) + if err != nil { + logger.Debug().Err(err).Msg("could not get school 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) +} + +// PostSchoolMember implements the Service interface. +func (g Graph) PostSchoolMember(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("Calling post school member") + + schoolID := chi.URLParam(r, "schoolID") + schoolID, err := url.PathUnescape(schoolID) + if err != nil { + logger.Debug(). + Err(err). + Str("id", schoolID). + Msg("could not add member to school: unescaping school id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") + return + } + + if schoolID == "" { + logger.Debug().Msg("could not add school member: missing school id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school 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 school 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 school 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 school 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", "school" and "organizational Contact" + // we restrict this to users for now. Might add Schools as members later + if memberType != "users" { + logger.Debug().Str("type", memberType).Msg("could not add school member: Only users are allowed as school members") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as school members") + return + } + + logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add member on backend") + err = g.identityEducationBackend.AddMembersToSchool(r.Context(), schoolID, []string{id}) + + if err != nil { + logger.Debug().Err(err).Msg("could not add school 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 + } + + e := events.SchoolMemberAdded{SchoolID: schoolID, UserID: id} + if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { + e.Executant = currentUser.GetId() + } + g.publishEvent(e) + + render.Status(r, http.StatusNoContent) + render.NoContent(w, r) +} + +// DeleteSchoolMember implements the Service interface. +func (g Graph) DeleteSchoolMember(w http.ResponseWriter, r *http.Request) { + logger := g.logger.SubloggerWithRequestID(r.Context()) + logger.Info().Msg("calling delete school member") + + schoolID := chi.URLParam(r, "schoolID") + schoolID, err := url.PathUnescape(schoolID) + if err != nil { + logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school member: unescaping school id failed") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed") + return + } + + if schoolID == "" { + logger.Debug().Msg("could not delete school member: missing school id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school 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 school 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 school member: missing member id") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id") + return + } + logger.Debug().Str("schoolID", schoolID).Str("memberID", memberID).Msg("calling delete member on backend") + err = g.identityEducationBackend.RemoveMemberFromSchool(r.Context(), schoolID, memberID) + + if err != nil { + logger.Debug().Err(err).Msg("could not delete school 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 + } + + e := events.SchoolMemberRemoved{SchoolID: schoolID, UserID: memberID} + if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { + e.Executant = currentUser.GetId() + } + g.publishEvent(e) + + render.Status(r, http.StatusNoContent) + render.NoContent(w, r) +} + +func sortSchools(req *godata.GoDataRequest, schools []*libregraph.EducationSchool) ([]*libregraph.EducationSchool, error) { + var sorter sort.Interface + if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 { + return schools, nil + } + switch req.Query.OrderBy.OrderByItems[0].Field.Value { + case "displayName": + sorter = schoolsByDisplayName{schools} + 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 == "desc" { + sorter = sort.Reverse(sorter) + } + sort.Sort(sorter) + return schools, nil +} diff --git a/services/graph/pkg/service/v0/schools_test.go b/services/graph/pkg/service/v0/schools_test.go new file mode 100644 index 0000000000..e4c4a55317 --- /dev/null +++ b/services/graph/pkg/service/v0/schools_test.go @@ -0,0 +1,465 @@ +package svc_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "net/http/httptest" + + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + ctxpkg "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" + 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 schoolList struct { + Value []*libregraph.EducationSchool +} + +var _ = Describe("Schools", func() { + var ( + svc service.Service + ctx context.Context + cfg *config.Config + gatewayClient *mocks.GatewayClient + eventsPublisher mocks.Publisher + identityEducationBackend *identitymocks.EducationBackend + + rr *httptest.ResponseRecorder + + newSchool *libregraph.EducationSchool + currentUser = &userv1beta1.User{ + Id: &userv1beta1.UserId{ + OpaqueId: "user", + }, + } + ) + + BeforeEach(func() { + eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + identityEducationBackend = &identitymocks.EducationBackend{} + gatewayClient = &mocks.GatewayClient{} + newSchool = libregraph.NewEducationSchool() + newSchool.SetId("school1") + + 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.WithIdentityEducationBackend(identityEducationBackend), + ) + }) + + Describe("GetSchools", func() { + It("handles invalid ODATA parameters", func() { + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?§foo=bar", nil) + svc.GetSchools(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles invalid sorting queries", func() { + identityEducationBackend.On("GetSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{newSchool}, nil) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?$orderby=invalid", nil) + svc.GetSchools(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + data, err := ioutil.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("GetSchools", ctx, mock.Anything).Return(nil, errors.New("failed")) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil) + svc.GetSchools(rr, r) + Expect(rr.Code).To(Equal(http.StatusInternalServerError)) + data, err := ioutil.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("GetSchools", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied")) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil) + svc.GetSchools(rr, r) + + Expect(rr.Code).To(Equal(http.StatusInternalServerError)) + data, err := ioutil.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 schools", func() { + identityEducationBackend.On("GetSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{}, nil) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil) + svc.GetSchools(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := ioutil.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 schools", func() { + identityEducationBackend.On("GetSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{newSchool}, nil) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil) + svc.GetSchools(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + data, err := ioutil.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + res := schoolList{} + err = json.Unmarshal(data, &res) + Expect(err).ToNot(HaveOccurred()) + + Expect(len(res.Value)).To(Equal(1)) + Expect(res.Value[0].GetId()).To(Equal("school1")) + }) + }) + + Describe("GetSchool", func() { + It("handles missing or empty school id", func() { + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil) + svc.GetSchool(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + + r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", "") + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx)) + svc.GetSchool(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + Context("with an existing school", func() { + BeforeEach(func() { + identityEducationBackend.On("GetSchool", mock.Anything, mock.Anything, mock.Anything).Return(newSchool, nil) + }) + + It("gets the school", func() { + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/"+*newSchool.Id, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", *newSchool.Id) + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx)) + + svc.GetSchool(rr, r) + + Expect(rr.Code).To(Equal(http.StatusOK)) + }) + }) + }) + + Describe("PostSchool", func() { + It("handles invalid body", func() { + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBufferString("{invalid")) + + svc.PostSchool(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles missing display name", func() { + newSchool = libregraph.NewEducationSchool() + newSchool.SetSchoolNumber("0034") + newSchoolJson, err := json.Marshal(newSchool) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson)) + + svc.PostSchool(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles missing school number", func() { + newSchool = libregraph.NewEducationSchool() + newSchool.SetDisplayName("New School") + newSchoolJson, err := json.Marshal(newSchool) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson)) + + svc.PostSchool(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("disallows school create ids", func() { + newSchool = libregraph.NewEducationSchool() + newSchool.SetId("disallowed") + newSchool.SetDisplayName("New School") + newSchool.SetSchoolNumber("0034") + newSchoolJson, err := json.Marshal(newSchool) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson)) + + svc.PostSchool(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles backend errors", func() { + newSchool = libregraph.NewEducationSchool() + newSchool.SetDisplayName("New School") + newSchool.SetSchoolNumber("0034") + newSchoolJson, err := json.Marshal(newSchool) + Expect(err).ToNot(HaveOccurred()) + + identityEducationBackend.On("CreateSchool", mock.Anything, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied")) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson)) + + svc.PostSchool(rr, r) + + Expect(rr.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("creates the school", func() { + newSchool = libregraph.NewEducationSchool() + newSchool.SetDisplayName("New School") + newSchoolJson, err := json.Marshal(newSchool) + Expect(err).ToNot(HaveOccurred()) + + identityEducationBackend.On("CreateSchool", mock.Anything, mock.Anything).Return(newSchool, nil) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson)) + + svc.PostSchool(rr, r) + + Expect(rr.Code).To(Equal(http.StatusCreated)) + }) + }) + Describe("PatchSchool", func() { + It("handles invalid body", func() { + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools/", bytes.NewBufferString("{invalid")) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", *newSchool.Id) + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchSchool(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles missing or empty school id", func() { + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools", nil) + svc.PatchSchool(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + + r = httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", "") + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchSchool(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("handles malformed school id", func() { + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", "school%id") + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PatchSchool(rr, r) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("updates the school", func() { + newSchool = libregraph.NewEducationSchool() + newSchool.SetDisplayName("New School Name") + newSchoolJson, err := json.Marshal(newSchool) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools/schoolid", bytes.NewBuffer(newSchoolJson)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", "school-id") + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + + svc.PatchSchool(rr, r) + + Expect(rr.Code).To(Equal(http.StatusNoContent)) + }) + }) + + Describe("DeleteSchool", func() { + Context("with an existing school", func() { + BeforeEach(func() { + identityEducationBackend.On("GetSchool", mock.Anything, mock.Anything, mock.Anything).Return(newSchool, nil) + }) + }) + + It("deletes the school", func() { + identityEducationBackend.On("DeleteSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil) + r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", *newSchool.Id) + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.DeleteSchool(rr, r) + + Expect(rr.Code).To(Equal(http.StatusNoContent)) + identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteSchool", 1) + eventsPublisher.AssertNumberOfCalls(GinkgoT(), "Publish", 1) + }) + }) + + Describe("GetSchoolMembers", func() { + It("gets the list of members", func() { + user := libregraph.NewUser() + user.SetId("user") + identityEducationBackend.On("GetSchoolMembers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.User{user}, nil) + + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/{schoolID}/members", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", *newSchool.Id) + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.GetSchoolMembers(rr, r) + Expect(rr.Code).To(Equal(http.StatusOK)) + + data, err := ioutil.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) + + var members []*libregraph.User + err = json.Unmarshal(data, &members) + Expect(err).ToNot(HaveOccurred()) + + Expect(len(members)).To(Equal(1)) + Expect(members[0].GetId()).To(Equal("user")) + }) + }) + + Describe("PostSchoolMembers", func() { + It("fails on invalid body", func() { + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBufferString("{invalid")) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", *newSchool.Id) + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PostSchoolMember(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/schools/{schoolID}/members", bytes.NewBuffer(data)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", *newSchool.Id) + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PostSchoolMember(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/schools/{schoolID}/members", bytes.NewBuffer(data)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", *newSchool.Id) + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PostSchoolMember(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()) + identityEducationBackend.On("AddMembersToSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", *newSchool.Id) + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.PostSchoolMember(rr, r) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + + identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToSchool", 1) + }) + }) + + Describe("DeleteSchoolMembers", func() { + It("handles missing or empty member id", func() { + r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{memberID}/$ref", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", *newSchool.Id) + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.DeleteSchoolMember(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/schools/{schoolID}/members/{memberID}/$ref", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("memberID", "/users/user") + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.DeleteSchoolMember(rr, r) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + + It("deletes members", func() { + identityEducationBackend.On("RemoveMemberFromSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{memberID}/$ref", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("schoolID", *newSchool.Id) + rctx.URLParams.Add("memberID", "/users/user1") + r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + svc.DeleteSchoolMember(rr, r) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + + identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveMemberFromSchool", 1) + }) + }) +}) diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index 3bd3a4ce5b..ffdb1ce69e 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -46,6 +46,15 @@ type Service interface { PostGroupMember(http.ResponseWriter, *http.Request) DeleteGroupMember(http.ResponseWriter, *http.Request) + GetSchools(http.ResponseWriter, *http.Request) + GetSchool(http.ResponseWriter, *http.Request) + PostSchool(http.ResponseWriter, *http.Request) + PatchSchool(http.ResponseWriter, *http.Request) + DeleteSchool(http.ResponseWriter, *http.Request) + GetSchoolMembers(http.ResponseWriter, *http.Request) + PostSchoolMember(http.ResponseWriter, *http.Request) + DeleteSchoolMember(http.ResponseWriter, *http.Request) + GetDrives(w http.ResponseWriter, r *http.Request) GetSingleDrive(w http.ResponseWriter, r *http.Request) GetAllDrives(w http.ResponseWriter, r *http.Request) @@ -67,13 +76,14 @@ func NewService(opts ...Option) Service { m.Use(options.Middleware...) svc := Graph{ - config: options.Config, - mux: m, - logger: &options.Logger, - spacePropertiesCache: ttlcache.NewCache(), - eventsPublisher: options.EventsPublisher, - gatewayClient: options.GatewayClient, - searchService: options.SearchService, + config: options.Config, + mux: m, + logger: &options.Logger, + spacePropertiesCache: ttlcache.NewCache(), + eventsPublisher: options.EventsPublisher, + gatewayClient: options.GatewayClient, + searchService: options.SearchService, + identityEducationBackend: options.IdentityEducationBackend, } if options.IdentityBackend == nil { @@ -129,10 +139,15 @@ func NewService(opts ...Option) Service { TLSConfig: tlsConf, }, ) - if svc.identityBackend, err = identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger); err != nil { + lb, err := identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger) + if err != nil { options.Logger.Error().Msgf("Error initializing LDAP Backend: '%s'", err) return nil } + svc.identityBackend = lb + if options.IdentityEducationBackend == nil { + svc.identityEducationBackend = lb + } default: options.Logger.Error().Msgf("Unknown Identity Backend: '%s'", options.Config.Identity.Backend) return nil @@ -215,6 +230,45 @@ func NewService(opts ...Option) Service { r.Delete("/", svc.DeleteDrive) }) }) + r.With(requireAdmin).Route("/education", func(r chi.Router) { + r.Route("/schools", func(r chi.Router) { + r.Get("/", svc.GetSchools) + r.Post("/", svc.PostSchool) + r.Route("/{schoolID}", func(r chi.Router) { + r.Get("/", svc.GetSchool) + r.Delete("/", svc.DeleteSchool) + r.Patch("/", svc.PatchSchool) + r.Route("/members", func(r chi.Router) { + r.Get("/", svc.GetSchoolMembers) + r.Post("/$ref", svc.PostSchoolMember) + r.Delete("/{memberID}/$ref", svc.DeleteSchoolMember) + }) + }) + }) + r.Route("/users", func(r chi.Router) { + r.Get("/", svc.GetUsers) + r.Post("/", svc.PostUser) + r.Route("/{userID}", func(r chi.Router) { + r.Get("/", svc.GetUser) + r.Delete("/", svc.DeleteUser) + r.Patch("/", svc.PatchUser) + }) + }) + 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.Route("/members", func(r chi.Router) { + r.Get("/", svc.GetGroupMembers) + r.Post("/$ref", svc.PostGroupMember) + r.Delete("/{memberID}/$ref", svc.DeleteGroupMember) + }) + }) + }) + }) }) }) diff --git a/services/graph/pkg/service/v0/tracing.go b/services/graph/pkg/service/v0/tracing.go index 357e23dfe8..0b6bd8d6f1 100644 --- a/services/graph/pkg/service/v0/tracing.go +++ b/services/graph/pkg/service/v0/tracing.go @@ -95,6 +95,46 @@ func (t tracing) DeleteGroupMember(w http.ResponseWriter, r *http.Request) { t.next.DeleteGroupMember(w, r) } +// GetSchools implements the Service interface. +func (t tracing) GetSchools(w http.ResponseWriter, r *http.Request) { + t.next.GetSchools(w, r) +} + +// GetSchool implements the Service interface. +func (t tracing) GetSchool(w http.ResponseWriter, r *http.Request) { + t.next.GetSchool(w, r) +} + +// PostSchool implements the Service interface. +func (t tracing) PostSchool(w http.ResponseWriter, r *http.Request) { + t.next.PostSchool(w, r) +} + +// PatchSchool implements the Service interface. +func (t tracing) PatchSchool(w http.ResponseWriter, r *http.Request) { + t.next.PatchSchool(w, r) +} + +// DeleteSchool implements the Service interface. +func (t tracing) DeleteSchool(w http.ResponseWriter, r *http.Request) { + t.next.DeleteSchool(w, r) +} + +// GetSchoolMembers implements the Service interface. +func (t tracing) GetSchoolMembers(w http.ResponseWriter, r *http.Request) { + t.next.GetSchoolMembers(w, r) +} + +// PostSchoolMember implements the Service interface. +func (t tracing) PostSchoolMember(w http.ResponseWriter, r *http.Request) { + t.next.PostSchoolMember(w, r) +} + +// DeleteSchoolMember implements the Service interface. +func (t tracing) DeleteSchoolMember(w http.ResponseWriter, r *http.Request) { + t.next.DeleteSchoolMember(w, r) +} + // GetDrives implements the Service interface. func (t tracing) GetDrives(w http.ResponseWriter, r *http.Request) { t.next.GetDrives(w, r)