graph:Add stubs for education/classes endpoints (#5360)

* Renamed files for consistency reasons

err_school.go implements the full education interface not just schools.
ldap_school.go renamed to ldap_education_school.go for making it
consistent with ldap_education_user.go

* graph: Add stubs for education/classes endpoints

The acutal backend implementations are still empty.
This commit is contained in:
Ralf Haferkamp
2023-01-11 20:38:18 +01:00
committed by GitHub
parent 92db62698d
commit 709ef1ffca
13 changed files with 1277 additions and 10 deletions
@@ -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
}
@@ -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)
})
})
})
+1 -1
View File
@@ -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
@@ -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)
+40
View File
@@ -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)
+18 -9
View File
@@ -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)
})
})
})
+40
View File
@@ -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)