diff --git a/README.md b/README.md index 239ca1b..851f59f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,20 @@ Implemented in go. Command line arguments ```-config [-migrate-database]``` +## Configuration File + +There is a template configuration file under `docs/config.example.yaml`. Copy it to `config.yaml` in the service +root (or wherever your `-config` argument points), and edit it to match your requirements. + +The sensitive values in the configuration file can also be specified via environment variables, so they can be +configured using a kubernetes secret or vault integration. If set, the environment variables override any +values in the configuration file, which are then allowed to be missing or empty. + +| Environment variable | Overrides configuration value | +|------------------------|-------------------------------| +| REG_SECRET_DB_PASSWORD | database.password | +| REG_SECRET_API_TOKEN | database.password | + ## Installation This service uses go modules to provide dependency management, see `go.mod`. diff --git a/api/openapi-spec/openapi.yaml b/api/openapi-spec/openapi.yaml index b304a43..35c8a75 100644 --- a/api/openapi-spec/openapi.yaml +++ b/api/openapi-spec/openapi.yaml @@ -71,11 +71,8 @@ paths: description: |- Obtain a list of all or selected groups, including their members. - Admin or Api Key authorization: can see all groups if the "show" - query parameter is set to "all". + Admin or Api Key authorization: can see all groups. - User authorization will only show groups visible to the current user, and only allows - "show" value "public". Normal users: can only see groups visible to them. If public groups are enabled in configuration, this means all groups that are public, not full, and from which the user wasn't banned. Not all fields will be filled. @@ -83,13 +80,6 @@ paths: Note: both admins and normal users can always use the findMyGroup operation to get the group they are in. operationId: listGroups parameters: - - name: show - in: query - description: set to "all" to request admin access rather than normal visibility rules. If permissions aren't sufficient, will result in a 403. Set to "public" to request only groups with flag "public". - schema: - type: string - example: public - default: public - name: member_ids in: query description: a comma separated list of badge numbers. The result will be limited to all groups that contain at least one of these badge numbers as members. Only admins can set this parameter, it is ignored for normal attendees to avoid leaking information. @@ -271,7 +261,7 @@ paths: tags: - groups summary: obtain a group by uuid - description: Returns a single group + description: Returns a single group. You must be a member of the group or an admin in order to have access. operationId: getGroupById parameters: - name: uuid @@ -435,12 +425,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - '409': - description: Group still contains members other than the owner. Must remove them first to ensure proper notifications. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' '500': description: An unexpected error occurred. This includes database errors. A best effort attempt is made to return details in the body. content: @@ -568,7 +552,7 @@ paths: schema: $ref: '#/components/schemas/Error' '409': - description: Duplicate assignment, this attendee is already in another group, or has been individually assigned to a room already. + description: Duplicate assignment, this attendee is already in another group. content: application/json: schema: @@ -1161,9 +1145,7 @@ paths: - rooms summary: add a whole group description: |- - Adds a group to a room. - - This locks the group against membership changes done by regular attendees. Admins may still change group membership. + Adds a group to a room. This is just a convenience function. Admin only. operationId: addGroupToRoom @@ -1229,7 +1211,9 @@ paths: - rooms summary: remove a whole group description: |- - Removes the group from the room. + Removes the group from the room. This is just a convenience function. + + If some members of the group are not in the room, their room assignments are left untouched. Admin only. operationId: removeGroupFromRoom @@ -1395,7 +1379,7 @@ components: example: 6 owner: type: integer - description: the badge number of the group owner. Must be a member of the group. If you are not an admin, you can only create groups with yourself as owner. + description: the badge number of the group owner. Must be a member of the group. If you are not an admin, you can only create groups with yourself as owner. When changing group owners, the current owner can assign any of the other members to become group owner. members: type: array description: the current group members. READ ONLY, provided for ease of use of the API, but completely ignored in all write requests. Please use the relevant subresource API endpoints to manipulate group membership. diff --git a/docs/config.example.yaml b/docs/config.example.yaml index 674b016..79645bb 100644 --- a/docs/config.example.yaml +++ b/docs/config.example.yaml @@ -1,8 +1,20 @@ service: - attendee_service_url: 'http://localhost:9091' # no trailing slash + attendee_service_url: 'http://localhost:9091' # base url to attendee service - no trailing slash + # the maximum size of room groups that attendees can form + # + # Should usually be set to the largest available room size, unless you have just one room that is much larger + # than all the others, then it may make sense to limit the group size to more typical room sizes. max_group_size: 6 + # allowed flags for groups. + # + # Flag "public" means a group is visible to approved attendees, who can then request to join it. If you + # do not allow this flag here, no public groups will be supported. group_flags: - public + # allowed flags for rooms. + # + # This service does not react to the flags, but UIs and exports may rely on presence of certain flags to + # function correctly. room_flags: - handicapped - final @@ -11,7 +23,7 @@ server: database: use: 'mysql' # or inmemory username: 'demouser' - password: 'demopw' + password: 'demopw' # can also leave blank and set REG_SECRET_DB_PASSWORD database: 'tcp(localhost:3306)/dbname' parameters: - 'charset=utf8mb4' @@ -21,6 +33,8 @@ database: security: cors: disable: false + fixed_token: + api: 'put_secure_random_string_here_for_api_token' # can also leave unset and set REG_SECRET_API_TOKEN oidc: id_token_cookie_name: JWT access_token_cookie_name: AUTH @@ -37,8 +51,10 @@ security: mwIDAQAB -----END PUBLIC KEY----- logging: - style: ecs + style: ecs # or plain severity: INFO +# this section is currently unused. It was used by the old email style hotel booking, see +# https://github.com/eurofurence/reg-hotel-booking go_live: public: start_iso_datetime: 1995-06-30T11:11:11+02:00 diff --git a/internal/api/v1/apimodel.go b/internal/api/v1/apimodel.go index e32cda5..19d9c1a 100644 --- a/internal/api/v1/apimodel.go +++ b/internal/api/v1/apimodel.go @@ -5,8 +5,8 @@ import "time" var _ = time.Now type Error struct { - // The time at which the error occurred. - Timestamp time.Time `json:"timestamp"` + // The time at which the error occurred, formatted as ISO datetime according to spec. + Timestamp string `json:"timestamp"` // An internal trace id assigned to the error. Used to find logs associated with errors across our services. Display to the user as something to communicate to us with inquiries about the error. Requestid string `json:"requestid"` // A keyed description of the error. We do not write human readable text here because the user interface will be multi language. At this time, there are these values: - auth.unauthorized (token missing completely or invalid) - auth.forbidden (permissions missing) @@ -25,9 +25,9 @@ type Group struct { // Optional comments the owner wishes to make regarding the group. Not processed in any way. Comments *string `yaml:"comments,omitempty" json:"comments,omitempty"` // if set higher than 0 (the default), will limit the number of people that can join the group. Note that there is also a configuration item that globally limits the size of groups, e.g. to the maximum room size. - MaximumSize *int32 `yaml:"maximum_size,omitempty" json:"maximum_size,omitempty"` + MaximumSize int64 `yaml:"maximum_size" json:"maximum_size"` // the badge number of the group owner. Must be a member of the group. If you are not an admin, you can only create groups with yourself as owner. - Owner int32 `yaml:"owner" json:"owner"` + Owner int64 `yaml:"owner" json:"owner"` // the current group members. READ ONLY, provided for ease of use of the API, but completely ignored in all write requests. Please use the relevant subresource API endpoints to manipulate group membership. Members []Member `yaml:"members,omitempty" json:"members,omitempty"` // the current outstanding invites for this group. READ ONLY, provided for ease of use of the API, but completely ignored in all write requests. Please use the relevant subresource API endpoints to send/revoke invites. @@ -41,8 +41,10 @@ type GroupCreate struct { Flags []string `yaml:"flags,omitempty" json:"flags,omitempty"` // Optional comments the owner wishes to make regarding the group. Not processed in any way. Comments *string `yaml:"comments,omitempty" json:"comments,omitempty"` + // if set higher than 0 (the default), will limit the number of people that can join the group. Note that there is also a configuration item that globally limits the size of groups, e.g. to the maximum room size. + MaximumSize int64 `yaml:"maximum_size" json:"maximum_size"` // the badge number of the group owner. If you are not an admin, you can only create groups with yourself as owner. Defaults to yourself. - Owner int32 `yaml:"owner" json:"owner"` + Owner int64 `yaml:"owner" json:"owner"` } type GroupList struct { @@ -51,7 +53,7 @@ type GroupList struct { type Member struct { // badge number (id in the attendee service). - ID int32 `yaml:"id" json:"id"` + ID int64 `yaml:"id" json:"id"` // The nickname of the attendee, proxied from that attendee service. Nickname string `yaml:"nickname" json:"nickname"` // A url to obtain the avatar for this attendee, points to an image such as a png or jpg. May require the same authentication this API expects. @@ -70,7 +72,7 @@ type Room struct { // Optional comment. Not processed in any way. Comments *string `yaml:"comments,omitempty" json:"comments,omitempty"` // the maximum room size, usually the number of sleeping spots/beds in the room. - Size int32 `yaml:"size" json:"size"` + Size int64 `yaml:"size" json:"size"` // the assigned room members. READ ONLY, provided for ease of use of the API, but completely ignored in all write requests. Please use the relevant subresource API endpoints to manipulate individual or group assignments. Members []Member `yaml:"members,omitempty" json:"members,omitempty"` } diff --git a/internal/application/app/application.go b/internal/application/app/application.go index 8e8750e..a92dbd9 100644 --- a/internal/application/app/application.go +++ b/internal/application/app/application.go @@ -42,6 +42,18 @@ func (a *Application) Run() error { aulogging.ErrorErrf(ctx, err, "failed to load configuration - bailing out: %s", err.Error()) return err } + aulogging.Info(ctx, "configuration file successfully loaded") + + aulogging.Info(ctx, "adding configuration defaults") + conf.AddDefaults() + aulogging.Info(ctx, "applying environment variable overrides") + conf.ApplyEnvironmentOverrides() + aulogging.Info(ctx, "validating configuration") + err = conf.Validate() + if err != nil { + aulogging.ErrorErrf(ctx, err, "failed to validate configuration - bailing out: %s", err.Error()) + return err + } if conf.Database.Use == config.Mysql { connectString := dbrepo.MysqlConnectString(conf.Database.Username, conf.Database.Password, conf.Database.Database, conf.Database.Parameters) diff --git a/internal/application/common/errors.go b/internal/application/common/errors.go index 3213f3b..341b043 100644 --- a/internal/application/common/errors.go +++ b/internal/application/common/errors.go @@ -15,6 +15,7 @@ type APIError interface { error Status() int Response() modelsv1.Error + InternalCauses() []error // not for sending to the client, but useful for logging } // ErrorMessageCode is a key to use for error messages in frontends or other automated systems interacting @@ -44,32 +45,32 @@ const ( // construct specific API errors -func NewBadRequest(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { - return NewAPIError(ctx, http.StatusBadRequest, message, details) +func NewBadRequest(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { + return NewAPIError(ctx, http.StatusBadRequest, message, details, internalCauses...) } -func NewUnauthorized(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { - return NewAPIError(ctx, http.StatusUnauthorized, message, details) +func NewUnauthorized(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { + return NewAPIError(ctx, http.StatusUnauthorized, message, details, internalCauses...) } -func NewForbidden(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { - return NewAPIError(ctx, http.StatusForbidden, message, details) +func NewForbidden(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { + return NewAPIError(ctx, http.StatusForbidden, message, details, internalCauses...) } -func NewNotFound(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { - return NewAPIError(ctx, http.StatusNotFound, message, details) +func NewNotFound(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { + return NewAPIError(ctx, http.StatusNotFound, message, details, internalCauses...) } -func NewConflict(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { - return NewAPIError(ctx, http.StatusConflict, message, details) +func NewConflict(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { + return NewAPIError(ctx, http.StatusConflict, message, details, internalCauses...) } -func NewInternalServerError(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { - return NewAPIError(ctx, http.StatusInternalServerError, message, details) +func NewInternalServerError(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { + return NewAPIError(ctx, http.StatusInternalServerError, message, details, internalCauses...) } -func NewBadGateway(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { - return NewAPIError(ctx, http.StatusBadGateway, message, details) +func NewBadGateway(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { + return NewAPIError(ctx, http.StatusBadGateway, message, details, internalCauses...) } // check for API errors @@ -107,24 +108,29 @@ func IsAPIError(err error) bool { return ok } +const isoDateTimeFormat = "2006-01-02T15:04:05-07:00" + // NewAPIError creates a generic API error from directly provided information. -func NewAPIError(ctx context.Context, status int, message ErrorMessageCode, details url.Values) APIError { +func NewAPIError(ctx context.Context, status int, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { + return &StatusError{ errStatus: status, response: modelsv1.Error{ - Timestamp: time.Now(), + Timestamp: time.Now().Format(isoDateTimeFormat), Requestid: GetRequestID(ctx), Message: string(message), Details: details, }, + internalCauses: internalCauses, } } var _ error = (*StatusError)(nil) type StatusError struct { - errStatus int - response modelsv1.Error + errStatus int + response modelsv1.Error + internalCauses []error } func (se *StatusError) Error() string { @@ -139,6 +145,10 @@ func (se *StatusError) Response() modelsv1.Error { return se.response } +func (se *StatusError) InternalCauses() []error { + return se.internalCauses +} + func isAPIErrorWithStatus(status int, err error) bool { apiError, ok := err.(APIError) return ok && status == apiError.Status() diff --git a/internal/application/web/endpoint.go b/internal/application/web/endpoint.go index 2e06678..5a084a8 100644 --- a/internal/application/web/endpoint.go +++ b/internal/application/web/endpoint.go @@ -13,7 +13,8 @@ type ( Endpoint[Req, Res any] func(ctx context.Context, request *Req, w http.ResponseWriter) (*Res, error) ) -func CreateHandler[Req, Res any](endpoint Endpoint[Req, Res], +func CreateHandler[Req, Res any]( + endpoint Endpoint[Req, Res], requestHandler RequestHandler[Req], responseHandler ResponseHandler[Res], ) http.Handler { @@ -36,24 +37,25 @@ func CreateHandler[Req, Res any](endpoint Endpoint[Req, Res], defer func() { err := r.Body.Close() if err != nil { - aulogging.ErrorErrf(ctx, err, "Error when closing the request body. [error]: %v", err) + aulogging.WarnErrf(ctx, err, "error while closing the request body: %v", err) } }() request, err := requestHandler(r, w) if err != nil { - aulogging.ErrorErrf(ctx, err, "An error occurred while parsing the request. [error]: %v", err) + SendErrorResponse(ctx, w, err) return } response, err := endpoint(ctx, request, w) if err != nil { - aulogging.ErrorErrf(ctx, err, "An error occurred during the request. [error]: %v", err) + SendErrorResponse(ctx, w, err) return } if err := responseHandler(ctx, response, w); err != nil { - aulogging.ErrorErrf(ctx, err, "An error occurred during the handling of the response. [error]: %v", err) + // cannot SendErrorResponse(ctx, w, err) - likely already have started sending in responseHandler + aulogging.ErrorErrf(ctx, err, "An error occurred during the handling of the response - response may have been incomplete. [error]: %v", err) } }) } diff --git a/internal/application/web/response.go b/internal/application/web/response.go index 0af529f..ea77e98 100644 --- a/internal/application/web/response.go +++ b/internal/application/web/response.go @@ -3,6 +3,7 @@ package web import ( "context" "encoding/json" + "fmt" aulogging "github.com/StephanHCB/go-autumn-logging" "github.com/eurofurence/reg-room-service/internal/application/common" "net/http" @@ -45,6 +46,11 @@ func SendErrorResponse(ctx context.Context, w http.ResponseWriter, err error) { // which contains relevant information about the failed request to the client. // The function will also set the http status according to the provided status. func SendAPIErrorResponse(ctx context.Context, w http.ResponseWriter, apiErr common.APIError) { + aulogging.InfoErrf(ctx, apiErr, fmt.Sprintf("api response status %d: %v", apiErr.Status(), apiErr)) + for _, cause := range apiErr.InternalCauses() { + aulogging.InfoErrf(ctx, cause, fmt.Sprintf("... caused by: %v", cause)) + } + w.WriteHeader(apiErr.Status()) EncodeToJSON(ctx, w, apiErr.Response()) diff --git a/internal/controller/v1/countdownctl/countdown_get.go b/internal/controller/v1/countdownctl/countdown_get.go index 59e6dc3..84203a3 100644 --- a/internal/controller/v1/countdownctl/countdown_get.go +++ b/internal/controller/v1/countdownctl/countdown_get.go @@ -61,8 +61,7 @@ func (*Handler) GetCountdownRequest(r *http.Request, w http.ResponseWriter) (*Ge aulogging.Warn(ctx, "mock time specified") mockTime, err := time.Parse(mockTimeFormat, currentTimeIsoParam) if err != nil { - web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details("mock time specified but failed to parse"))) - return nil, err + return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details("mock time specified but failed to parse")) } return &GetCountdownRequest{mockTime: &mockTime}, nil } diff --git a/internal/controller/v1/groupsctl/groups_delete.go b/internal/controller/v1/groupsctl/groups_delete.go index dc92548..b0f05a2 100644 --- a/internal/controller/v1/groupsctl/groups_delete.go +++ b/internal/controller/v1/groupsctl/groups_delete.go @@ -2,13 +2,10 @@ package groupsctl import ( "context" - "github.com/eurofurence/reg-room-service/internal/application/web" - "github.com/eurofurence/reg-room-service/internal/controller/v1/util" "github.com/go-chi/chi/v5" "net/http" modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" - "github.com/eurofurence/reg-room-service/internal/application/common" ) // DeleteGroupRequest holds information, which is required to call the DeleteGroup operation. @@ -27,7 +24,7 @@ func (h *Controller) DeleteGroup(ctx context.Context, req *DeleteGroupRequest, w // DeleteGroupRequest parses and returns a request containing information to call the DeleteGroup function. func (h *Controller) DeleteGroupRequest(r *http.Request, w http.ResponseWriter) (*DeleteGroupRequest, error) { groupID := chi.URLParam(r, "uuid") - if err := validateGroupID(r.Context(), w, groupID); err != nil { + if err := validateGroupID(r.Context(), groupID); err != nil { return nil, err } @@ -39,60 +36,3 @@ func (h *Controller) DeleteGroupResponse(_ context.Context, _ *modelsv1.Empty, w w.WriteHeader(http.StatusNoContent) return nil } - -// RemoveGroupMemberRequest holds information, which is required to call the RemoveGroupMember operation. -type RemoveGroupMemberRequest struct { - groupID string - badgeNumber uint -} - -// RemoveGroupMember removes a group member or revokes an invitation -// -// Removes the attendee with the given badge number from the group (or its list of invitations). -// Possibly also add an entry to the group's auto-deny list. -// -// *Permissions* -// -// * Group owners can remove members/revoke invitations. -// * Members can remove themselves/decline invitations. -// * Admins can remove anyone/revoke their invitations. -// -// *Limitations* -// -// If a member is the current group owner, this fails with 409 conflict. First must reassign the group owner via -// an update to the group resource. -// -// *Auto-Deny* -// -// If the auto deny parameter is set to true, in addition to removing the group membership/invitation, the -// badge number is added to an auto-decline list. Further attempts to invite this attendee into the group -// are automatically declined. -func (h *Controller) RemoveGroupMember(ctx context.Context, req *RemoveGroupMemberRequest, w http.ResponseWriter) (*modelsv1.Empty, error) { - return nil, nil -} - -// RemoveGroupMemberRequest validates and creates the request for the RemoveGroupMember operation. -func (h *Controller) RemoveGroupMemberRequest(r *http.Request, w http.ResponseWriter) (*RemoveGroupMemberRequest, error) { - const uuidParam, badeNumberParam = "uuid", "badgenumber" - - groupID := chi.URLParam(r, uuidParam) - if err := validateGroupID(r.Context(), w, groupID); err != nil { - return nil, err - } - - badgeNumber, err := util.ParseUInt[uint](chi.URLParam(r, badeNumberParam)) - if err != nil { - ctx := r.Context() - web.SendErrorResponse(ctx, w, - common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid type for badge number"))) - return nil, err - } - - return &RemoveGroupMemberRequest{groupID, badgeNumber}, nil -} - -// RemoveGroupMemberResponse writes out a `No Content` status. -func (h *Controller) RemoveGroupMemberResponse(_ context.Context, _ *modelsv1.Empty, w http.ResponseWriter) error { - w.WriteHeader(http.StatusNoContent) - return nil -} diff --git a/internal/controller/v1/groupsctl/groups_get.go b/internal/controller/v1/groupsctl/groups_get.go index 9493ad6..8719b56 100644 --- a/internal/controller/v1/groupsctl/groups_get.go +++ b/internal/controller/v1/groupsctl/groups_get.go @@ -15,7 +15,7 @@ import ( ) type ListGroupsRequest struct { - MemberIDs []uint + MemberIDs []int64 MinSize uint MaxSize int } @@ -23,7 +23,6 @@ type ListGroupsRequest struct { func (h *Controller) ListGroups(ctx context.Context, req *ListGroupsRequest, w http.ResponseWriter) (*modelsv1.GroupList, error) { groups, err := h.svc.FindGroups(ctx, req.MinSize, req.MaxSize, req.MemberIDs) if err != nil { - web.SendErrorResponse(ctx, w, err) return nil, err } @@ -41,16 +40,14 @@ func (h *Controller) ListGroupsRequest(r *http.Request, w http.ResponseWriter) ( queryIDs := query.Get("member_ids") memberIDs, err := util.ParseMemberIDs(queryIDs) if err != nil { - web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details(err.Error()))) - return nil, err + return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error())) } req.MemberIDs = memberIDs if minSize := query.Get("min_size"); minSize != "" { val, err := util.ParseUInt[uint](minSize) if err != nil { - web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details(err.Error()))) - return nil, err + return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error())) } req.MinSize = val @@ -59,12 +56,10 @@ func (h *Controller) ListGroupsRequest(r *http.Request, w http.ResponseWriter) ( if maxSize := query.Get("max_size"); maxSize != "" { val, err := util.ParseInt[int](maxSize) if err != nil { - web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details(err.Error()))) - return nil, err + return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error())) } if val < -1 { - web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("maxSize cannot be less than -1"))) - return nil, err + return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details("maxSize cannot be less than -1")) } req.MaxSize = val @@ -84,7 +79,6 @@ type FindMyGroupRequest struct{} func (h *Controller) FindMyGroup(ctx context.Context, req *FindMyGroupRequest, w http.ResponseWriter) (*modelsv1.Group, error) { group, err := h.svc.FindMyGroup(ctx) if err != nil { - web.SendErrorResponse(ctx, w, err) return nil, err } @@ -107,7 +101,6 @@ type FindGroupByIDRequest struct { func (h *Controller) FindGroupByID(ctx context.Context, req *FindGroupByIDRequest, w http.ResponseWriter) (*modelsv1.Group, error) { grp, err := h.svc.GetGroupByID(ctx, req.GroupID) if err != nil { - web.SendErrorResponse(ctx, w, err) return nil, err } @@ -117,9 +110,7 @@ func (h *Controller) FindGroupByID(ctx context.Context, req *FindGroupByIDReques func (h *Controller) FindGroupByIDRequest(r *http.Request, w http.ResponseWriter) (*FindGroupByIDRequest, error) { groupID := chi.URLParam(r, "uuid") if _, err := uuid.Parse(groupID); err != nil { - ctx := r.Context() - web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupIDInvalid, url.Values{})) - return nil, err + return nil, common.NewBadRequest(r.Context(), common.GroupIDInvalid, url.Values{"details": []string{"you must specify a valid uuid"}}) } req := &FindGroupByIDRequest{ diff --git a/internal/controller/v1/groupsctl/groups_post.go b/internal/controller/v1/groupsctl/groups_post.go index 7fd32a4..5549284 100644 --- a/internal/controller/v1/groupsctl/groups_post.go +++ b/internal/controller/v1/groupsctl/groups_post.go @@ -2,8 +2,7 @@ package groupsctl import ( "context" - aulogging "github.com/StephanHCB/go-autumn-logging" - "github.com/eurofurence/reg-room-service/internal/application/web" + "errors" "github.com/eurofurence/reg-room-service/internal/controller/v1/util" "net/http" "net/url" @@ -29,15 +28,12 @@ type CreateGroupRequest struct { func (h *Controller) CreateGroup(ctx context.Context, req *CreateGroupRequest, w http.ResponseWriter) (*modelsv1.Empty, error) { newGroupUUID, err := h.svc.CreateGroup(ctx, req.Group) if err != nil { - web.SendErrorResponse(ctx, w, err) return nil, err } requestURL, ok := ctx.Value(common.CtxKeyRequestURL{}).(*url.URL) if !ok { - aulogging.Error(ctx, "could not retrieve base URL from context") - web.SendErrorResponse(ctx, w, nil) - return nil, nil + return nil, errors.New("could not retrieve base URL from context - this is an implementation error") } w.Header().Set("Location", path.Join(requestURL.Path, newGroupUUID)) @@ -48,11 +44,7 @@ func (h *Controller) CreateGroupRequest(r *http.Request, w http.ResponseWriter) var group modelsv1.GroupCreate if err := util.NewStrictJSONDecoder(r.Body).Decode(&group); err != nil { - ctx := r.Context() - web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, - common.GroupDataInvalid, common.Details("please check if your provided JSON is valid"), - )) - return nil, err + return nil, common.NewBadRequest(r.Context(), common.GroupDataInvalid, common.Details("invalid json provided")) } cgr := &CreateGroupRequest{ diff --git a/internal/controller/v1/groupsctl/groups_put.go b/internal/controller/v1/groupsctl/groups_put.go index 829115e..75f6a16 100644 --- a/internal/controller/v1/groupsctl/groups_put.go +++ b/internal/controller/v1/groupsctl/groups_put.go @@ -2,12 +2,9 @@ package groupsctl import ( "context" - "fmt" - aulogging "github.com/StephanHCB/go-autumn-logging" - "github.com/eurofurence/reg-room-service/internal/application/web" + "errors" "github.com/eurofurence/reg-room-service/internal/controller/v1/util" "github.com/go-chi/chi/v5" - "github.com/google/uuid" "net/http" "net/url" @@ -21,17 +18,15 @@ type UpdateGroupRequest struct { // UpdateGroup is used to update an existing group by uuid. Note that you cannot use this to change the group members! // -// Admins or the current group owner can change the group owner to any member of the group. +// Admins or the current group owner can change the group owner to any member of the group. func (h *Controller) UpdateGroup(ctx context.Context, req *UpdateGroupRequest, w http.ResponseWriter) (*modelsv1.Empty, error) { if err := h.svc.UpdateGroup(ctx, req.Group); err != nil { - web.SendErrorResponse(ctx, w, err) return nil, err } reqURL, ok := ctx.Value(common.CtxKeyRequestURL{}).(*url.URL) if !ok { - aulogging.Error(ctx, "unable to retrieve URL from context") - return nil, nil + return nil, errors.New("unable to retrieve URL from context - this is an implementation error") } w.Header().Set("Location", reqURL.Path) @@ -43,16 +38,14 @@ func (h *Controller) UpdateGroupRequest(r *http.Request, w http.ResponseWriter) ctx := r.Context() groupID := chi.URLParam(r, "uuid") - if err := uuid.Validate(groupID); err != nil { - web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupIDInvalid, common.Details(fmt.Sprintf("%q is not a vailid UUID", groupID)))) + if err := validateGroupID(ctx, groupID); err != nil { return nil, err } var group modelsv1.Group if err := util.NewStrictJSONDecoder(r.Body).Decode(&group); err != nil { - web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid json provided"))) - return nil, err + return nil, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid json provided")) } group.ID = groupID diff --git a/internal/controller/v1/groupsctl/members_delete.go b/internal/controller/v1/groupsctl/members_delete.go new file mode 100644 index 0000000..32ffac7 --- /dev/null +++ b/internal/controller/v1/groupsctl/members_delete.go @@ -0,0 +1,51 @@ +package groupsctl + +import ( + "context" + "github.com/eurofurence/reg-room-service/internal/controller/v1/util" + "github.com/go-chi/chi/v5" + "net/http" + + modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" + "github.com/eurofurence/reg-room-service/internal/application/common" +) + +// RemoveGroupMemberRequest holds information, which is required to call the RemoveGroupMember operation. +type RemoveGroupMemberRequest struct { + groupID string + badgeNumber uint +} + +// RemoveGroupMember removes a group member or revokes an invitation. +// +// Details see OpenAPI spec. +func (h *Controller) RemoveGroupMember(ctx context.Context, req *RemoveGroupMemberRequest, w http.ResponseWriter) (*modelsv1.Empty, error) { + // TODO + + return nil, nil +} + +// RemoveGroupMemberRequest validates and creates the request for the RemoveGroupMember operation. +func (h *Controller) RemoveGroupMemberRequest(r *http.Request, w http.ResponseWriter) (*RemoveGroupMemberRequest, error) { + const uuidParam, badeNumberParam = "uuid", "badgenumber" + + ctx := r.Context() + + groupID := chi.URLParam(r, uuidParam) + if err := validateGroupID(ctx, groupID); err != nil { + return nil, err + } + + badgeNumber, err := util.ParseUInt[uint](chi.URLParam(r, badeNumberParam)) + if err != nil { + return nil, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid type for badge number")) + } + + return &RemoveGroupMemberRequest{groupID, badgeNumber}, nil +} + +// RemoveGroupMemberResponse writes out a `No Content` status. +func (h *Controller) RemoveGroupMemberResponse(_ context.Context, _ *modelsv1.Empty, w http.ResponseWriter) error { + w.WriteHeader(http.StatusNoContent) + return nil +} diff --git a/internal/controller/v1/groupsctl/members_post.go b/internal/controller/v1/groupsctl/members_post.go index a9ac590..bdeae45 100644 --- a/internal/controller/v1/groupsctl/members_post.go +++ b/internal/controller/v1/groupsctl/members_post.go @@ -2,32 +2,21 @@ package groupsctl import ( "context" - "github.com/eurofurence/reg-room-service/internal/application/web" "github.com/eurofurence/reg-room-service/internal/controller/v1/util" groupservice "github.com/eurofurence/reg-room-service/internal/service/groups" "net/http" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" "github.com/eurofurence/reg-room-service/internal/application/common" + "github.com/go-chi/chi/v5" ) // AddMemberToGroup adds an attendee to a group. -// Group owners may use this to send an invitation email. The invite email will contain a link with a code which -// then allows the invited person to add themselves. -// -// Admins can add the force query parameter to just add. If they do not specify force=true, they are subject -// to the same limitations as every normal user. -// -// Users may only add themselves, and only if they have a valid invite code, and if they are registered for the convention. // -// If an attendee is already in a group, or has already been individually assigned to a room, then they -// cannot be added to a group anymore. -// -// If a group has already been assigned to a room, then only admins can change their members. +// Details see OpenAPI spec. func (h *Controller) AddMemberToGroup(ctx context.Context, req *groupservice.AddGroupMemberParams, w http.ResponseWriter) (*modelsv1.Empty, error) { + // TODO + return &modelsv1.Empty{}, h.svc.AddMemberToGroup(ctx, *req) } @@ -36,16 +25,17 @@ func (h *Controller) AddMemberToGroupRequest(r *http.Request, w http.ResponseWri ctx := r.Context() groupID := chi.URLParam(r, "uuid") - if _, err := uuid.Parse(groupID); err != nil { - web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupIDInvalid, nil)) + if err := validateGroupID(ctx, groupID); err != nil { return nil, err } badge := chi.URLParam(r, "badgenumber") - badgeNumber, err := util.ParseUInt[uint](badge) + badgeNumber, err := util.ParseInt[int64](badge) if err != nil { - web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid type for badge number"))) - return nil, err + return nil, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid badge number - must be positive integer"), err) + } + if badgeNumber < 1 { + return nil, common.NewBadRequest(ctx, common.GroupDataInvalid, common.Details("invalid badge number - must be positive integer")) } query := r.URL.Query() @@ -58,8 +48,7 @@ func (h *Controller) AddMemberToGroupRequest(r *http.Request, w http.ResponseWri force, err := util.ParseOptionalBool(query.Get("force")) if err != nil { - web.SendErrorResponse(ctx, w, common.NewBadRequest(ctx, common.GroupDataInvalid, nil)) - return nil, err + return nil, common.NewBadRequest(ctx, common.GroupDataInvalid, nil, err) } req.Force = force diff --git a/internal/controller/v1/groupsctl/utils.go b/internal/controller/v1/groupsctl/utils.go index c86395b..1597766 100644 --- a/internal/controller/v1/groupsctl/utils.go +++ b/internal/controller/v1/groupsctl/utils.go @@ -4,18 +4,13 @@ import ( "context" "fmt" "github.com/eurofurence/reg-room-service/internal/application/common" - "github.com/eurofurence/reg-room-service/internal/application/web" "github.com/google/uuid" - "net/http" + "net/url" ) -func validateGroupID(ctx context.Context, w http.ResponseWriter, groupID string) error { +func validateGroupID(ctx context.Context, groupID string) error { if err := uuid.Validate(groupID); err != nil { - web.SendErrorResponse(ctx, w, - common.NewBadRequest(ctx, common.GroupIDInvalid, common.Details(fmt.Sprintf("%q is not a vailid UUID", groupID))), - ) - - return err + return common.NewBadRequest(ctx, common.GroupIDInvalid, common.Details(fmt.Sprintf("'%s' is not a valid UUID", url.PathEscape(groupID))), err) } return nil diff --git a/internal/controller/v1/util/util.go b/internal/controller/v1/util/util.go index 6fff32d..397e299 100644 --- a/internal/controller/v1/util/util.go +++ b/internal/controller/v1/util/util.go @@ -18,10 +18,10 @@ func NewStrictJSONDecoder(r io.Reader) *json.Decoder { } // ParseMemberIDs is a helper function to parse member IDs for groups and rooms. -func ParseMemberIDs(ids string) ([]uint, error) { - var res []uint +func ParseMemberIDs(ids string) ([]int64, error) { + var res []int64 if ids != "" { - res = make([]uint, 0) + res = make([]int64, 0) // we expect IDs to be provided as a comma separated list // ids must be numeric. If any ID is invalid we want to return an error @@ -34,7 +34,7 @@ func ParseMemberIDs(ids string) ([]uint, error) { if id <= 0 { return nil, fmt.Errorf("member ids must be positive. Invalid member id: %s", url.QueryEscape(memberID)) } - res = append(res, uint(id)) + res = append(res, int64(id)) } } diff --git a/internal/entity/group.go b/internal/entity/group.go index 6dc0fd9..4225b72 100644 --- a/internal/entity/group.go +++ b/internal/entity/group.go @@ -14,10 +14,10 @@ type Group struct { Comments string `gorm:"type:varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci" testdiff:"ignore"` // MaximumSize defaults to a value from service configuration, but we store it here so admins can increase it manually for some groups - MaximumSize uint + MaximumSize int64 // Owner is the badge number (attendee ID) of the attendee owning the group. Ownership can be passed to another attendee. - Owner uint + Owner int64 } // GroupMember associates attendees to a group, either as a member or as an invited member. diff --git a/internal/entity/member.go b/internal/entity/member.go index 2e07a97..b4c11bc 100644 --- a/internal/entity/member.go +++ b/internal/entity/member.go @@ -7,7 +7,7 @@ import ( type Member struct { // ID contains the badge number of the attendee (an attendee can only either be in a // group or invited, and can only ever be in one room at the same time). - ID uint `gorm:"primaryKey"` + ID int64 `gorm:"primaryKey"` CreatedAt time.Time UpdatedAt time.Time diff --git a/internal/entity/room.go b/internal/entity/room.go index 012df99..678dce1 100644 --- a/internal/entity/room.go +++ b/internal/entity/room.go @@ -13,7 +13,7 @@ type Room struct { Comments string `gorm:"type:varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci" testdiff:"ignore"` // Size is the size of the room - Size uint + Size int64 } type RoomMember struct { diff --git a/internal/repository/config/config.go b/internal/repository/config/config.go index 3ea5021..9575a2a 100644 --- a/internal/repository/config/config.go +++ b/internal/repository/config/config.go @@ -39,7 +39,7 @@ type ( // for service related tasks. E.g. URL to attendee service. ServiceConfig struct { AttendeeServiceURL string `yaml:"attendee_service_url"` - MaxGroupSize uint `yaml:"max_group_size"` + MaxGroupSize int64 `yaml:"max_group_size"` GroupFlags []string `yaml:"group_flags"` RoomFlags []string `yaml:"room_flags"` } diff --git a/internal/repository/config/environment.go b/internal/repository/config/environment.go new file mode 100644 index 0000000..dff3741 --- /dev/null +++ b/internal/repository/config/environment.go @@ -0,0 +1,17 @@ +package config + +import "os" + +const ( + envDbPassword = "REG_SECRET_DB_PASSWORD" + envApiToken = "REG_SECRET_API_TOKEN" +) + +func (c *Config) ApplyEnvironmentOverrides() { + if dbPassword := os.Getenv(envDbPassword); dbPassword != "" { + c.Database.Password = dbPassword + } + if apiToken := os.Getenv(envApiToken); apiToken != "" { + c.Security.Fixed.API = apiToken + } +} diff --git a/internal/repository/database/historizeddb/implementation.go b/internal/repository/database/historizeddb/implementation.go index 39c6057..6cdf00e 100644 --- a/internal/repository/database/historizeddb/implementation.go +++ b/internal/repository/database/historizeddb/implementation.go @@ -60,7 +60,7 @@ func (r *HistorizingRepository) AddGroup(ctx context.Context, group *entity.Grou return r.wrappedRepository.AddGroup(ctx, group) } -func (r *HistorizingRepository) FindGroups(ctx context.Context, minOccupancy uint, maxOccupancy int, anyOfMemberID []uint) ([]string, error) { +func (r *HistorizingRepository) FindGroups(ctx context.Context, minOccupancy uint, maxOccupancy int, anyOfMemberID []int64) ([]string, error) { return r.wrappedRepository.FindGroups(ctx, minOccupancy, maxOccupancy, anyOfMemberID) } @@ -88,33 +88,23 @@ func (r *HistorizingRepository) GetGroupByID(ctx context.Context, id string) (*e return r.wrappedRepository.GetGroupByID(ctx, id) } -func (r *HistorizingRepository) SoftDeleteGroupByID(ctx context.Context, id string) error { +func (r *HistorizingRepository) DeleteGroupByID(ctx context.Context, id string) error { histEntry := noDiffRecord(ctx, typeGroup, id, opDelete) if err := r.wrappedRepository.RecordHistory(ctx, histEntry); err != nil { return err } - return r.wrappedRepository.SoftDeleteGroupByID(ctx, id) -} - -func (r *HistorizingRepository) UndeleteGroupByID(ctx context.Context, id string) error { - histEntry := noDiffRecord(ctx, typeGroup, id, opUndelete) - - if err := r.wrappedRepository.RecordHistory(ctx, histEntry); err != nil { - return err - } - - return r.wrappedRepository.UndeleteGroupByID(ctx, id) + return r.wrappedRepository.DeleteGroupByID(ctx, id) } // group members -func (r *HistorizingRepository) NewEmptyGroupMembership(ctx context.Context, groupID string, attendeeID uint) *entity.GroupMember { - return r.wrappedRepository.NewEmptyGroupMembership(ctx, groupID, attendeeID) +func (r *HistorizingRepository) NewEmptyGroupMembership(ctx context.Context, groupID string, attendeeID int64, nickname string) *entity.GroupMember { + return r.wrappedRepository.NewEmptyGroupMembership(ctx, groupID, attendeeID, nickname) } -func (r *HistorizingRepository) GetGroupMembershipByAttendeeID(ctx context.Context, attendeeID uint) (*entity.GroupMember, error) { +func (r *HistorizingRepository) GetGroupMembershipByAttendeeID(ctx context.Context, attendeeID int64) (*entity.GroupMember, error) { return r.wrappedRepository.GetGroupMembershipByAttendeeID(ctx, attendeeID) } @@ -146,7 +136,7 @@ func (r *HistorizingRepository) UpdateGroupMembership(ctx context.Context, gm *e return r.wrappedRepository.UpdateGroupMembership(ctx, gm) } -func (r *HistorizingRepository) DeleteGroupMembership(ctx context.Context, attendeeID uint) error { +func (r *HistorizingRepository) DeleteGroupMembership(ctx context.Context, attendeeID int64) error { histEntry := noDiffRecord(ctx, typeGroupMember, fmt.Sprintf("%d", attendeeID), opDelete) if err := r.wrappedRepository.RecordHistory(ctx, histEntry); err != nil { @@ -190,33 +180,23 @@ func (r *HistorizingRepository) GetRoomByID(ctx context.Context, id string) (*en return r.wrappedRepository.GetRoomByID(ctx, id) } -func (r *HistorizingRepository) SoftDeleteRoomByID(ctx context.Context, id string) error { +func (r *HistorizingRepository) DeleteRoomByID(ctx context.Context, id string) error { histEntry := noDiffRecord(ctx, typeRoom, id, opDelete) if err := r.wrappedRepository.RecordHistory(ctx, histEntry); err != nil { return err } - return r.wrappedRepository.SoftDeleteRoomByID(ctx, id) -} - -func (r *HistorizingRepository) UndeleteRoomByID(ctx context.Context, id string) error { - histEntry := noDiffRecord(ctx, typeRoom, id, opUndelete) - - if err := r.wrappedRepository.RecordHistory(ctx, histEntry); err != nil { - return err - } - - return r.wrappedRepository.UndeleteRoomByID(ctx, id) + return r.wrappedRepository.DeleteRoomByID(ctx, id) } // room members -func (r *HistorizingRepository) NewEmptyRoomMembership(ctx context.Context, roomID string, attendeeID uint) *entity.RoomMember { +func (r *HistorizingRepository) NewEmptyRoomMembership(ctx context.Context, roomID string, attendeeID int64) *entity.RoomMember { return r.wrappedRepository.NewEmptyRoomMembership(ctx, roomID, attendeeID) } -func (r *HistorizingRepository) GetRoomMembershipByAttendeeID(ctx context.Context, attendeeID uint) (*entity.RoomMember, error) { +func (r *HistorizingRepository) GetRoomMembershipByAttendeeID(ctx context.Context, attendeeID int64) (*entity.RoomMember, error) { return r.wrappedRepository.GetRoomMembershipByAttendeeID(ctx, attendeeID) } @@ -248,7 +228,7 @@ func (r *HistorizingRepository) UpdateRoomMembership(ctx context.Context, rm *en return r.wrappedRepository.UpdateRoomMembership(ctx, rm) } -func (r *HistorizingRepository) DeleteRoomMembership(ctx context.Context, attendeeID uint) error { +func (r *HistorizingRepository) DeleteRoomMembership(ctx context.Context, attendeeID int64) error { histEntry := noDiffRecord(ctx, typeRoomMember, fmt.Sprintf("%d", attendeeID), opDelete) if err := r.wrappedRepository.RecordHistory(ctx, histEntry); err != nil { diff --git a/internal/repository/database/inmemorydb/implementation.go b/internal/repository/database/inmemorydb/implementation.go index 91ad35c..cda9f2e 100644 --- a/internal/repository/database/inmemorydb/implementation.go +++ b/internal/repository/database/inmemorydb/implementation.go @@ -3,6 +3,7 @@ package inmemorydb import ( "context" "fmt" + "slices" "sync/atomic" "time" @@ -68,7 +69,7 @@ func (r *InMemoryRepository) GetGroups(_ context.Context) ([]*entity.Group, erro return result, nil } -func (r *InMemoryRepository) FindGroups(ctx context.Context, minOccupancy uint, maxOccupancy int, anyOfMemberID []uint) ([]string, error) { +func (r *InMemoryRepository) FindGroups(ctx context.Context, minOccupancy uint, maxOccupancy int, anyOfMemberID []int64) ([]string, error) { result := make([]string, 0) for _, grp := range r.groups { if !grp.Group.DeletedAt.Valid { @@ -88,6 +89,7 @@ func (r *InMemoryRepository) FindGroups(ctx context.Context, minOccupancy uint, } } } + slices.Sort(result) return result, nil } @@ -116,24 +118,9 @@ func (r *InMemoryRepository) GetGroupByID(_ context.Context, id string) (*entity } } -func (r *InMemoryRepository) SoftDeleteGroupByID(_ context.Context, id string) error { - if result, ok := r.groups[id]; ok { - result.Group.DeletedAt = gorm.DeletedAt{ - Time: r.Now(), - Valid: true, - } - return nil - } else { - return gorm.ErrRecordNotFound - } -} - -func (r *InMemoryRepository) UndeleteGroupByID(_ context.Context, id string) error { - if result, ok := r.groups[id]; ok { - result.Group.DeletedAt = gorm.DeletedAt{ - Time: r.Now(), - Valid: false, - } +func (r *InMemoryRepository) DeleteGroupByID(_ context.Context, id string) error { + if _, ok := r.groups[id]; ok { + delete(r.groups, id) return nil } else { return gorm.ErrRecordNotFound @@ -142,15 +129,16 @@ func (r *InMemoryRepository) UndeleteGroupByID(_ context.Context, id string) err // group members -func (r *InMemoryRepository) NewEmptyGroupMembership(_ context.Context, groupID string, attendeeID uint) *entity.GroupMember { +func (r *InMemoryRepository) NewEmptyGroupMembership(_ context.Context, groupID string, attendeeID int64, nickname string) *entity.GroupMember { var m entity.GroupMember m.ID = attendeeID + m.Nickname = nickname m.GroupID = groupID m.IsInvite = true // default to invite because that's the usual starting point return &m } -func (r *InMemoryRepository) GetGroupMembershipByAttendeeID(_ context.Context, attendeeID uint) (*entity.GroupMember, error) { +func (r *InMemoryRepository) GetGroupMembershipByAttendeeID(_ context.Context, attendeeID int64) (*entity.GroupMember, error) { for _, grp := range r.groups { for _, gm := range grp.Members { if gm.ID == attendeeID { @@ -211,7 +199,7 @@ func (r *InMemoryRepository) UpdateGroupMembership(ctx context.Context, gm *enti } } -func (r *InMemoryRepository) DeleteGroupMembership(ctx context.Context, attendeeID uint) error { +func (r *InMemoryRepository) DeleteGroupMembership(ctx context.Context, attendeeID int64) error { current, err := r.GetGroupMembershipByAttendeeID(ctx, attendeeID) if err != nil { return err @@ -268,24 +256,9 @@ func (r *InMemoryRepository) GetRoomByID(ctx context.Context, id string) (*entit } } -func (r *InMemoryRepository) SoftDeleteRoomByID(ctx context.Context, id string) error { - if result, ok := r.rooms[id]; ok { - result.Room.DeletedAt = gorm.DeletedAt{ - Time: r.Now(), - Valid: true, - } - return nil - } else { - return gorm.ErrRecordNotFound - } -} - -func (r *InMemoryRepository) UndeleteRoomByID(ctx context.Context, id string) error { - if result, ok := r.rooms[id]; ok { - result.Room.DeletedAt = gorm.DeletedAt{ - Time: r.Now(), - Valid: false, - } +func (r *InMemoryRepository) DeleteRoomByID(ctx context.Context, id string) error { + if _, ok := r.rooms[id]; ok { + delete(r.rooms, id) return nil } else { return gorm.ErrRecordNotFound @@ -294,14 +267,14 @@ func (r *InMemoryRepository) UndeleteRoomByID(ctx context.Context, id string) er // room members -func (r *InMemoryRepository) NewEmptyRoomMembership(_ context.Context, roomID string, attendeeID uint) *entity.RoomMember { +func (r *InMemoryRepository) NewEmptyRoomMembership(_ context.Context, roomID string, attendeeID int64) *entity.RoomMember { var m entity.RoomMember m.ID = attendeeID m.RoomID = roomID return &m } -func (r *InMemoryRepository) GetRoomMembershipByAttendeeID(_ context.Context, attendeeID uint) (*entity.RoomMember, error) { +func (r *InMemoryRepository) GetRoomMembershipByAttendeeID(_ context.Context, attendeeID int64) (*entity.RoomMember, error) { for _, room := range r.rooms { for _, mem := range room.Members { if mem.ID == attendeeID { @@ -362,7 +335,7 @@ func (r *InMemoryRepository) UpdateRoomMembership(ctx context.Context, rm *entit } } -func (r *InMemoryRepository) DeleteRoomMembership(ctx context.Context, attendeeID uint) error { +func (r *InMemoryRepository) DeleteRoomMembership(ctx context.Context, attendeeID int64) error { current, err := r.GetRoomMembershipByAttendeeID(ctx, attendeeID) if err != nil { return err diff --git a/internal/repository/database/interface.go b/internal/repository/database/interface.go index 734eb65..a7d8f0f 100644 --- a/internal/repository/database/interface.go +++ b/internal/repository/database/interface.go @@ -20,38 +20,36 @@ type Repository interface { // // A group matches the list of badge numbers in anyOfMemberID if at least one of those badge numbers // is a member of the group. An empty list means no condition. - FindGroups(ctx context.Context, minOccupancy uint, maxOccupancy int, anyOfMemberID []uint) ([]string, error) + FindGroups(ctx context.Context, minOccupancy uint, maxOccupancy int, anyOfMemberID []int64) ([]string, error) AddGroup(ctx context.Context, group *entity.Group) (string, error) UpdateGroup(ctx context.Context, group *entity.Group) error GetGroupByID(ctx context.Context, id string) (*entity.Group, error) // may return soft deleted entities! - SoftDeleteGroupByID(ctx context.Context, id string) error - UndeleteGroupByID(ctx context.Context, id string) error + DeleteGroupByID(ctx context.Context, id string) error // NewEmptyGroupMembership pre-fills some required and internal fields, including the // groupID and attendeeID. - NewEmptyGroupMembership(ctx context.Context, groupID string, attendeeID uint) *entity.GroupMember - GetGroupMembershipByAttendeeID(ctx context.Context, attendeeID uint) (*entity.GroupMember, error) + NewEmptyGroupMembership(ctx context.Context, groupID string, attendeeID int64, nickname string) *entity.GroupMember + GetGroupMembershipByAttendeeID(ctx context.Context, attendeeID int64) (*entity.GroupMember, error) GetGroupMembersByGroupID(ctx context.Context, groupID string) ([]*entity.GroupMember, error) AddGroupMembership(ctx context.Context, gm *entity.GroupMember) error UpdateGroupMembership(ctx context.Context, gm *entity.GroupMember) error - DeleteGroupMembership(ctx context.Context, attendeeID uint) error + DeleteGroupMembership(ctx context.Context, attendeeID int64) error // GetRooms returns all non-soft-deleted rooms. GetRooms(ctx context.Context) ([]*entity.Room, error) AddRoom(ctx context.Context, room *entity.Room) (string, error) UpdateRoom(ctx context.Context, room *entity.Room) error GetRoomByID(ctx context.Context, id string) (*entity.Room, error) // may return soft deleted entities! - SoftDeleteRoomByID(ctx context.Context, id string) error - UndeleteRoomByID(ctx context.Context, id string) error + DeleteRoomByID(ctx context.Context, id string) error // NewEmptyRoomMembership pre-fills some required and internal fields, including the // RoomID and attendeeID. - NewEmptyRoomMembership(ctx context.Context, roomID string, attendeeID uint) *entity.RoomMember - GetRoomMembershipByAttendeeID(ctx context.Context, attendeeID uint) (*entity.RoomMember, error) + NewEmptyRoomMembership(ctx context.Context, roomID string, attendeeID int64) *entity.RoomMember + GetRoomMembershipByAttendeeID(ctx context.Context, attendeeID int64) (*entity.RoomMember, error) GetRoomMembersByRoomID(ctx context.Context, roomID string) ([]*entity.RoomMember, error) AddRoomMembership(ctx context.Context, rm *entity.RoomMember) error UpdateRoomMembership(ctx context.Context, rm *entity.RoomMember) error - DeleteRoomMembership(ctx context.Context, attendeeID uint) error + DeleteRoomMembership(ctx context.Context, attendeeID int64) error RecordHistory(ctx context.Context, h *entity.History) error } diff --git a/internal/repository/database/mysqldb/implementation.go b/internal/repository/database/mysqldb/implementation.go index 8bfbd7c..82bede2 100644 --- a/internal/repository/database/mysqldb/implementation.go +++ b/internal/repository/database/mysqldb/implementation.go @@ -138,13 +138,13 @@ func (r *MysqlRepository) GetGroups(ctx context.Context) ([]*entity.Group, error return getAllNonDeleted[entity.Group](ctx, r.db, groupDesc) } -func (r *MysqlRepository) FindGroups(ctx context.Context, minOccupancy uint, maxOccupancy int, anyOfMemberID []uint) ([]string, error) { +func (r *MysqlRepository) FindGroups(ctx context.Context, minOccupancy uint, maxOccupancy int, anyOfMemberID []int64) ([]string, error) { query, params := buildFindQuery(minOccupancy, maxOccupancy, anyOfMemberID) return r.findGroupIDsByQuery(ctx, query, params) } -func buildFindQuery(minOccupancy uint, maxOccupancy int, anyOfMemberID []uint) (string, map[string]any) { +func buildFindQuery(minOccupancy uint, maxOccupancy int, anyOfMemberID []int64) (string, map[string]any) { params := make(map[string]any) query := strings.Builder{} query.WriteString("SELECT g.id AS id FROM room_groups g WHERE (@use_named_params = 1) ") @@ -209,17 +209,14 @@ func (r *MysqlRepository) GetGroupByID(ctx context.Context, id string) (*entity. return getByID[entity.Group](ctx, r.db, id, groupDesc) } -func (r *MysqlRepository) SoftDeleteGroupByID(ctx context.Context, id string) error { - return softDeleteByID[entity.Group](ctx, r.db, id, groupDesc) +func (r *MysqlRepository) DeleteGroupByID(ctx context.Context, id string) error { + return deleteByID[entity.Group](ctx, r.db, id, groupDesc) } -func (r *MysqlRepository) UndeleteGroupByID(ctx context.Context, id string) error { - return undeleteByID[entity.Group](ctx, r.db, id, groupDesc) -} - -func (r *MysqlRepository) NewEmptyGroupMembership(_ context.Context, groupID string, attendeeID uint) *entity.GroupMember { +func (r *MysqlRepository) NewEmptyGroupMembership(_ context.Context, groupID string, attendeeID int64, nickname string) *entity.GroupMember { var m entity.GroupMember m.ID = attendeeID + m.Nickname = nickname m.GroupID = groupID m.IsInvite = true // default to invite because that's the usual starting point return &m @@ -227,7 +224,7 @@ func (r *MysqlRepository) NewEmptyGroupMembership(_ context.Context, groupID str const groupMembershipDesc = "group membership" -func (r *MysqlRepository) GetGroupMembershipByAttendeeID(ctx context.Context, attendeeID uint) (*entity.GroupMember, error) { +func (r *MysqlRepository) GetGroupMembershipByAttendeeID(ctx context.Context, attendeeID int64) (*entity.GroupMember, error) { var m entity.GroupMember m.ID = attendeeID return getMembershipByAttendeeID[entity.GroupMember](ctx, r.db, attendeeID, &m, groupMembershipDesc) @@ -245,7 +242,7 @@ func (r *MysqlRepository) UpdateGroupMembership(ctx context.Context, gm *entity. return updateMembership[entity.GroupMember](ctx, r.db, gm, groupMembershipDesc) } -func (r *MysqlRepository) DeleteGroupMembership(ctx context.Context, attendeeID uint) error { +func (r *MysqlRepository) DeleteGroupMembership(ctx context.Context, attendeeID int64) error { return deleteMembership[entity.GroupMember](ctx, r.db, attendeeID, groupMembershipDesc) } @@ -269,24 +266,20 @@ func (r *MysqlRepository) GetRoomByID(ctx context.Context, id string) (*entity.R return getByID[entity.Room](ctx, r.db, id, roomDesc) } -func (r *MysqlRepository) SoftDeleteRoomByID(ctx context.Context, id string) error { - return softDeleteByID[entity.Room](ctx, r.db, id, roomDesc) -} - -func (r *MysqlRepository) UndeleteRoomByID(ctx context.Context, id string) error { - return undeleteByID[entity.Room](ctx, r.db, id, roomDesc) +func (r *MysqlRepository) DeleteRoomByID(ctx context.Context, id string) error { + return deleteByID[entity.Room](ctx, r.db, id, roomDesc) } const roomMembershipDesc = "room membership" -func (r *MysqlRepository) NewEmptyRoomMembership(_ context.Context, roomID string, attendeeID uint) *entity.RoomMember { +func (r *MysqlRepository) NewEmptyRoomMembership(_ context.Context, roomID string, attendeeID int64) *entity.RoomMember { var m entity.RoomMember m.ID = attendeeID m.RoomID = roomID return &m } -func (r *MysqlRepository) GetRoomMembershipByAttendeeID(ctx context.Context, attendeeID uint) (*entity.RoomMember, error) { +func (r *MysqlRepository) GetRoomMembershipByAttendeeID(ctx context.Context, attendeeID int64) (*entity.RoomMember, error) { var m entity.RoomMember m.ID = attendeeID return getMembershipByAttendeeID[entity.RoomMember](ctx, r.db, attendeeID, &m, roomMembershipDesc) @@ -304,7 +297,7 @@ func (r *MysqlRepository) UpdateRoomMembership(ctx context.Context, rm *entity.R return updateMembership[entity.RoomMember](ctx, r.db, rm, roomMembershipDesc) } -func (r *MysqlRepository) DeleteRoomMembership(ctx context.Context, attendeeID uint) error { +func (r *MysqlRepository) DeleteRoomMembership(ctx context.Context, attendeeID int64) error { return deleteMembership[entity.RoomMember](ctx, r.db, attendeeID, roomMembershipDesc) } @@ -372,7 +365,7 @@ func getByID[E anyMemberCollection]( return &g, err } -func softDeleteByID[E anyMemberCollection]( +func deleteByID[E anyMemberCollection]( ctx context.Context, db *gorm.DB, id string, @@ -384,7 +377,7 @@ func softDeleteByID[E anyMemberCollection]( aulogging.WarnErrf(ctx, err, "mysql error during %s soft delete - %s not found: %s", logDescription, logDescription, err.Error()) return err } - err = db.Delete(&g).Error + err = db.Unscoped().Delete(&g).Error if err != nil { aulogging.WarnErrf(ctx, err, "mysql error during %s soft delete - deletion failed: %s", logDescription, err.Error()) return err @@ -392,26 +385,6 @@ func softDeleteByID[E anyMemberCollection]( return nil } -func undeleteByID[E anyMemberCollection]( - ctx context.Context, - db *gorm.DB, - id string, - logDescription string, -) error { - var g E - err := db.Unscoped().First(&g, id).Error - if err != nil { - aulogging.WarnErrf(ctx, err, "mysql error during %s undelete - %s not found: %s", logDescription, logDescription, err.Error()) - return err - } - err = db.Unscoped().Model(&g).Where("id", id).Update("deleted_at", nil).Error - if err != nil { - aulogging.WarnErrf(ctx, err, "mysql error during %s undelete: %s", logDescription, err.Error()) - return err - } - return nil -} - type anyMembership interface { entity.GroupMember | entity.RoomMember } @@ -419,7 +392,7 @@ type anyMembership interface { func getMembershipByAttendeeID[E anyMembership]( ctx context.Context, db *gorm.DB, - attendeeID uint, + attendeeID int64, defaultValue *E, logDescription string, ) (*E, error) { @@ -475,7 +448,7 @@ func updateMembership[E anyMembership]( func deleteMembership[E anyMembership]( ctx context.Context, db *gorm.DB, - id uint, + id int64, logDescription string, ) error { var m E diff --git a/internal/repository/downstreams/attendeeservice/client.go b/internal/repository/downstreams/attendeeservice/client.go index 6bf2509..696973a 100644 --- a/internal/repository/downstreams/attendeeservice/client.go +++ b/internal/repository/downstreams/attendeeservice/client.go @@ -14,8 +14,9 @@ import ( ) type Impl struct { - myTokenClient aurestclientapi.Client - baseUrl string + myTokenClient aurestclientapi.Client + apiTokenClient aurestclientapi.Client + baseUrl string } func New(attendeeServiceBaseUrl string) (AttendeeService, error) { @@ -35,9 +36,18 @@ func New(attendeeServiceBaseUrl string) (AttendeeService, error) { return nil, err } + apiTokenClient, err := downstreams.ClientWith( + downstreams.ApiTokenRequestManipulator(conf.Security.Fixed.API), + "attendee-service-apitoken-breaker", + ) + if err != nil { + return nil, err + } + return &Impl{ - myTokenClient: myTokenClient, - baseUrl: attendeeServiceBaseUrl, + myTokenClient: myTokenClient, + apiTokenClient: apiTokenClient, + baseUrl: attendeeServiceBaseUrl, }, nil } @@ -80,3 +90,13 @@ func (i *Impl) GetStatus(ctx context.Context, id int64) (Status, error) { } return bodyDto.Status, downstreams.ErrByStatus(err, response.Status) } + +func (i *Impl) GetAttendee(ctx context.Context, id int64) (Attendee, error) { + url := fmt.Sprintf("%s/api/rest/v1/attendees/%d", i.baseUrl, id) + bodyDto := Attendee{} + response := aurestclientapi.ParsedResponse{ + Body: &bodyDto, + } + err := i.myTokenClient.Perform(ctx, http.MethodGet, url, nil, &response) + return bodyDto, downstreams.ErrByStatus(err, response.Status) +} diff --git a/internal/repository/downstreams/attendeeservice/interface.go b/internal/repository/downstreams/attendeeservice/interface.go index 40339c7..2b22460 100644 --- a/internal/repository/downstreams/attendeeservice/interface.go +++ b/internal/repository/downstreams/attendeeservice/interface.go @@ -17,6 +17,21 @@ var ( StatusDeleted Status = "deleted" ) +type Attendee struct { + ID int64 `json:"id"` // badge number + Nickname string `json:"nickname"` // fan name + + Email string `json:"email"` + + SpokenLanguages string `json:"spoken_languages"` // configurable subset of configured language codes, comma separated (de,en) + RegistrationLanguage string `json:"registration_language"` // one out of configurable subset of RFC 5646 locales (default en-US) + + // comma separated lists, allowed choices are convention dependent + Flags string `json:"flags"` // hc,anon,ev + Packages string `json:"packages"` // room-none,attendance,stage,sponsor,sponsor2 + Options string `json:"options"` // art,anim,music,suit +} + type AttendeeService interface { // ListMyRegistrationIds which attendee ids belong to the current user? // @@ -36,4 +51,11 @@ type AttendeeService interface { // // Forwards the jwt from the request. GetStatus(ctx context.Context, id int64) (Status, error) + + // GetAttendee obtains part of the registration information for given attendee id. + // + // Used for internal nickname lookups, etc. + // + // Uses the api token for full access, so access control must be performed in the implementation. + GetAttendee(ctx context.Context, id int64) (Attendee, error) } diff --git a/internal/repository/downstreams/attendeeservice/mock.go b/internal/repository/downstreams/attendeeservice/mock.go index b3ff2c9..0734a99 100644 --- a/internal/repository/downstreams/attendeeservice/mock.go +++ b/internal/repository/downstreams/attendeeservice/mock.go @@ -12,12 +12,13 @@ type Mock interface { Reset() Unavailable() - SetupRegistered(subject string, badgeNo int64, status Status) + SetupRegistered(subject string, badgeNo int64, status Status, nickname string, email string) } type MockImpl struct { IdsBySubject map[string][]int64 StatusById map[int64]Status + AttendeeById map[int64]Attendee IsUnavailable bool } @@ -69,9 +70,23 @@ func (m *MockImpl) GetStatus(ctx context.Context, id int64) (Status, error) { return status, nil } +func (m *MockImpl) GetAttendee(ctx context.Context, id int64) (Attendee, error) { + if m.IsUnavailable { + return Attendee{}, downstreams.ErrDownStreamUnavailable + } + + attendee, ok := m.AttendeeById[id] + if !ok { + return Attendee{}, downstreams.ErrByStatus(nil, 404) + } + + return attendee, nil +} + func (m *MockImpl) Reset() { m.IdsBySubject = make(map[string][]int64) m.StatusById = make(map[int64]Status) + m.AttendeeById = make(map[int64]Attendee) m.IsUnavailable = false } @@ -79,7 +94,12 @@ func (m *MockImpl) Unavailable() { m.IsUnavailable = true } -func (m *MockImpl) SetupRegistered(subject string, badgeNo int64, status Status) { +func (m *MockImpl) SetupRegistered(subject string, badgeNo int64, status Status, nickname string, email string) { m.IdsBySubject[subject] = []int64{badgeNo} m.StatusById[badgeNo] = status + m.AttendeeById[badgeNo] = Attendee{ + ID: badgeNo, + Nickname: nickname, + Email: email, + } } diff --git a/internal/service/groups/groups.go b/internal/service/groups/groups.go index 4f1c1cd..4c37091 100644 --- a/internal/service/groups/groups.go +++ b/internal/service/groups/groups.go @@ -27,7 +27,7 @@ type Service interface { UpdateGroup(ctx context.Context, group modelsv1.Group) error DeleteGroup(ctx context.Context, groupID string) error AddMemberToGroup(ctx context.Context, req AddGroupMemberParams) error - FindGroups(ctx context.Context, minSize uint, maxSize int, memberIDs []uint) ([]*modelsv1.Group, error) + FindGroups(ctx context.Context, minSize uint, maxSize int, memberIDs []int64) ([]*modelsv1.Group, error) FindMyGroup(ctx context.Context) (*modelsv1.Group, error) } @@ -49,12 +49,12 @@ type groupService struct { // // Uses the attendee service to look up the badge number. func (g *groupService) FindMyGroup(ctx context.Context) (*modelsv1.Group, error) { - myID, err := g.loggedInUserValidRegistrationBadgeNo(ctx) + attendee, err := g.loggedInUserValidRegistrationBadgeNo(ctx) if err != nil { return nil, err } - groups, err := g.findGroupsLowlevel(ctx, 0, -1, []uint{uint(myID)}) + groups, err := g.findGroupsLowlevel(ctx, 0, -1, []int64{attendee.ID}) if err != nil { return nil, err } @@ -79,7 +79,7 @@ func (g *groupService) FindMyGroup(ctx context.Context) (*modelsv1.Group, error) // Normal users: can only see groups visible to them. If public groups are enabled in configuration, // this means all groups that are public and from which the user wasn't banned. Not all fields // will be filled in the results to protect the privacy of group members. -func (g *groupService) FindGroups(ctx context.Context, minSize uint, maxSize int, memberIDs []uint) ([]*modelsv1.Group, error) { +func (g *groupService) FindGroups(ctx context.Context, minSize uint, maxSize int, memberIDs []int64) ([]*modelsv1.Group, error) { validator, err := rbac.NewValidator(ctx) if err != nil { aulogging.ErrorErrf(ctx, err, "Could not retrieve RBAC validator from context. [error]: %v", err) @@ -92,7 +92,7 @@ func (g *groupService) FindGroups(ctx context.Context, minSize uint, maxSize int result := make([]*modelsv1.Group, 0) // ensure attending registration - myID, err := g.loggedInUserValidRegistrationBadgeNo(ctx) + attendee, err := g.loggedInUserValidRegistrationBadgeNo(ctx) if err != nil { return result, err } @@ -107,9 +107,8 @@ func (g *groupService) FindGroups(ctx context.Context, minSize uint, maxSize int // if not public, only show the group if user is in it // if public, show the group but filter out member info for _, group := range unchecked { - if groupContains(group, int32(myID)) || groupInvited(group, int32(myID)) || groupHasFlag(group, "public") { - // TODO config constant "public", configure available flags in configuration - result = append(result, publicInfo(group, int32(myID))) + if groupContains(group, attendee.ID) || groupInvited(group, attendee.ID) || groupHasFlag(group, "public") { + result = append(result, publicInfo(group, attendee.ID)) } } @@ -119,7 +118,7 @@ func (g *groupService) FindGroups(ctx context.Context, minSize uint, maxSize int } } -func (g *groupService) findGroupsLowlevel(ctx context.Context, minSize uint, maxSize int, memberIDs []uint) ([]*modelsv1.Group, error) { +func (g *groupService) findGroupsLowlevel(ctx context.Context, minSize uint, maxSize int, memberIDs []int64) ([]*modelsv1.Group, error) { result := make([]*modelsv1.Group, 0) groupIDs, err := g.DB.FindGroups(ctx, minSize, maxSize, memberIDs) @@ -149,6 +148,24 @@ func (g *groupService) findGroupsLowlevel(ctx context.Context, minSize uint, max // GetGroupByID attempts to retrieve a group and its members from the database by a given ID. func (g *groupService) GetGroupByID(ctx context.Context, groupID string) (*modelsv1.Group, error) { + validator, err := rbac.NewValidator(ctx) + if err != nil { + aulogging.ErrorErrf(ctx, err, "Could not retrieve RBAC validator from context. [error]: %v", err) + return nil, errCouldNotGetValidator(ctx) + } + + if validator.IsAdmin() { + // admins are allowed access + } else if validator.IsUser() { + // ensure attending registration + _, err := g.loggedInUserValidRegistrationBadgeNo(ctx) + if err != nil { + return nil, err + } + } else { + return nil, errNotAttending(ctx) // shouldn't ever happen, just in case + } + grp, err := g.DB.GetGroupByID(ctx, groupID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -170,8 +187,8 @@ func (g *groupService) GetGroupByID(ctx context.Context, groupID string) (*model Name: grp.Name, Flags: aggregateFlags(grp.Flags), Comments: &grp.Comments, - MaximumSize: common.To(int32(grp.MaximumSize)), - Owner: int32(grp.Owner), + MaximumSize: grp.MaximumSize, + Owner: grp.Owner, Members: toMembers(groupMembers), Invites: nil, // TODO }, nil @@ -188,19 +205,28 @@ func (g *groupService) CreateGroup(ctx context.Context, group modelsv1.GroupCrea return "", errCouldNotGetValidator(ctx) } - var ownerID uint + var ownerID int64 + var nickname string if validator.IsAdmin() { - ownerID = uint(group.Owner) + ownerID = group.Owner + if ownerID > 0 { + attendee, err := g.AttSrv.GetAttendee(ctx, int64(ownerID)) + if err != nil { + return "", err + } + nickname = attendee.Nickname + } } if ownerID == 0 { - myID, err := g.loggedInUserValidRegistrationBadgeNo(ctx) + attendee, err := g.loggedInUserValidRegistrationBadgeNo(ctx) if err != nil { return "", err } - ownerID = uint(myID) + ownerID = attendee.ID + nickname = attendee.Nickname } - validation := validateGroup(group) + validation := validateGroupCreate(group) if len(validation) > 0 { return "", common.NewBadRequest(ctx, common.GroupDataInvalid, validation) } @@ -218,20 +244,28 @@ func (g *groupService) CreateGroup(ctx context.Context, group modelsv1.GroupCrea return "", err } - gm := g.DB.NewEmptyGroupMembership(ctx, groupID, ownerID) + gm := g.DB.NewEmptyGroupMembership(ctx, groupID, ownerID, nickname) return groupID, g.DB.AddGroupMembership(ctx, gm) } -func validateGroup(group modelsv1.GroupCreate) url.Values { +func validateGroupCreate(group modelsv1.GroupCreate) url.Values { + return validate(group.Name, group.Flags) +} + +func validateGroup(group modelsv1.Group) url.Values { + return validate(group.Name, group.Flags) +} + +func validate(name string, flags []string) url.Values { result := url.Values{} - if len(group.Name) == 0 { + if len(name) == 0 { result.Set("name", "group name cannot be empty") } - if len(group.Name) > 50 { + if len(name) > 50 { result.Set("name", "group name too long, max 50 characters") } allowed := allowedFlags() - for _, flag := range group.Flags { + for _, flag := range flags { if !util.SliceContains(flag, allowed) { result.Set("flags", fmt.Sprintf("no such flag '%s'", url.PathEscape(flag))) } @@ -244,7 +278,7 @@ type AddGroupMemberParams struct { // GroupID is the ID of the group where a user should be added GroupID string // BadgeNumber is the registration number of a user - BadgeNumber uint + BadgeNumber int64 // Nickname is the nickname of a registered user that should receive // an invitation Email. Nickname string @@ -257,7 +291,7 @@ type AddGroupMemberParams struct { // AddMemberToGroup TODO... func (g *groupService) AddMemberToGroup(ctx context.Context, req AddGroupMemberParams) error { - gm := g.DB.NewEmptyGroupMembership(ctx, req.GroupID, req.BadgeNumber) + gm := g.DB.NewEmptyGroupMembership(ctx, req.GroupID, req.BadgeNumber, req.Nickname) err := g.DB.AddGroupMembership(ctx, gm) if err != nil { @@ -277,12 +311,6 @@ func (g *groupService) UpdateGroup(ctx context.Context, group modelsv1.Group) er return errInternal(ctx, "unexpected error when parsing user claims") } - badgeNumber, err := util.ParseUInt[uint](validator.Subject()) - if err != nil { - aulogging.WarnErrf(ctx, err, "subject has an unexpected value %q", validator.Subject()) - return errInternal(ctx, "subject should have a valid numerical value") - } - getGroup, err := g.DB.GetGroupByID(ctx, group.ID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -290,27 +318,37 @@ func (g *groupService) UpdateGroup(ctx context.Context, group modelsv1.Group) er } } + if validator.IsAdmin() || validator.IsAPITokenCall() { + // admins and api token are allowed to make changes to any group + } else if validator.IsUser() { + attendee, err := g.loggedInUserValidRegistrationBadgeNo(ctx) + if err != nil { + return err + } + + if int64(getGroup.Owner) != attendee.ID { + return common.NewForbidden(ctx, common.AuthForbidden, common.Details("only the group owner or an admin can change a group")) + } + } else { + return errNotAttending(ctx) // shouldn't ever happen, just in case + } + + validation := validateGroup(group) + if len(validation) > 0 { + return common.NewBadRequest(ctx, common.GroupDataInvalid, validation) + } + updateGroup := &entity.Group{ Base: entity.Base{ID: group.ID}, Name: group.Name, Flags: fmt.Sprintf(",%s,", strings.Join(group.Flags, ",")), Comments: common.Deref(group.Comments), - MaximumSize: uint(common.Deref(group.MaximumSize)), - } - - // Changes to the group owner can only be instigated by either the group owner - // or forcefully by the admin. - // In both cases a new owner can only be an already existing member in the group. - switch { - case validator.IsAdmin(): - fallthrough - case getGroup.Owner == badgeNumber && group.Owner != int32(getGroup.Owner): - if getGroup.Owner == uint(group.Owner) { - // we are not changing the owner here - break - } + MaximumSize: group.MaximumSize, + Owner: group.Owner, + } - err := g.changeGroupOwner(ctx, group, updateGroup) + if getGroup.Owner != group.Owner { + err := g.canChangeGroupOwner(ctx, group) if err != nil { return err } @@ -319,7 +357,7 @@ func (g *groupService) UpdateGroup(ctx context.Context, group modelsv1.Group) er return g.DB.UpdateGroup(ctx, updateGroup) } -func (g *groupService) changeGroupOwner(ctx context.Context, group modelsv1.Group, updateGroup *entity.Group) error { +func (g *groupService) canChangeGroupOwner(ctx context.Context, group modelsv1.Group) error { members, err := g.DB.GetGroupMembersByGroupID(ctx, group.ID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -330,15 +368,14 @@ func (g *groupService) changeGroupOwner(ctx context.Context, group modelsv1.Grou } found := false for _, member := range members { - if member.ID == uint(group.Owner) { + if member.ID == group.Owner { found = true break } } if !found { - return errGroupHasNoMembers(ctx) + return errNewOwnerNotMember(ctx) } - updateGroup.Owner = uint(group.Owner) return nil } @@ -359,20 +396,19 @@ func (g *groupService) DeleteGroup(ctx context.Context, groupID string) error { return errGroupRead(ctx, "error retrieving group - see logs for details") } - if group.DeletedAt.Valid { - // group is already deleted - aulogging.Warnf(ctx, "group %s was already marked for deletion", groupID) - return nil - } - - badgeNumber, err := util.ParseUInt[uint](validator.Subject()) - if err != nil { - aulogging.ErrorErrf(ctx, err, "subject has an unexpected value %q", validator.Subject()) - return errInternal(ctx, "subject should have a valid numerical value - this is a bug, please report it") - } + if validator.IsAdmin() || validator.IsAPITokenCall() { + // admins and api token are allowed to make changes to any group + } else if validator.IsUser() { + attendee, err := g.loggedInUserValidRegistrationBadgeNo(ctx) + if err != nil { + return err + } - if !validator.IsAdmin() || badgeNumber == group.Owner { - return common.NewForbidden(ctx, common.AuthForbidden, common.Details("only the group owner or an admin can delete a group")) + if int64(group.Owner) != attendee.ID { + return common.NewForbidden(ctx, common.AuthForbidden, common.Details("only the group owner or an admin can delete a group")) + } + } else { + return errNotAttending(ctx) // shouldn't ever happen, just in case } members, err := g.DB.GetGroupMembersByGroupID(ctx, groupID) @@ -392,7 +428,7 @@ func (g *groupService) DeleteGroup(ctx context.Context, groupID string) error { } } - if err := g.DB.SoftDeleteGroupByID(ctx, groupID); err != nil { + if err := g.DB.DeleteGroupByID(ctx, groupID); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errGroupIDNotFound(ctx) } @@ -412,7 +448,7 @@ func toMembers(groupMembers []*entity.GroupMember) []modelsv1.Member { } member := modelsv1.Member{ - ID: int32(m.ID), + ID: m.ID, Nickname: m.Nickname, } if m.AvatarURL != "" { @@ -447,13 +483,17 @@ func errNoGroup(ctx context.Context) error { } func errGroupIDNotFound(ctx context.Context) error { - return common.NewNotFound(ctx, common.GroupIDNotFound, common.Details("unable to find group in database")) + return common.NewNotFound(ctx, common.GroupIDNotFound, common.Details("group does not exist")) } func errGroupHasNoMembers(ctx context.Context) error { return common.NewInternalServerError(ctx, common.GroupMemberNotFound, common.Details("unable to find members in group")) } +func errNewOwnerNotMember(ctx context.Context) error { + return common.NewInternalServerError(ctx, common.GroupMemberNotFound, common.Details("new owner must be a member of the group")) +} + func errCouldNotGetValidator(ctx context.Context) error { return common.NewInternalServerError(ctx, common.InternalErrorMessage, common.Details("unexpected error when parsing user claims")) } diff --git a/internal/service/groups/helpers.go b/internal/service/groups/helpers.go index a6896ec..ad8faa9 100644 --- a/internal/service/groups/helpers.go +++ b/internal/service/groups/helpers.go @@ -9,22 +9,30 @@ import ( "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" ) -func (g *groupService) loggedInUserValidRegistrationBadgeNo(ctx context.Context) (int64, error) { +func (g *groupService) loggedInUserValidRegistrationBadgeNo(ctx context.Context) (attendeeservice.Attendee, error) { myRegIDs, err := g.AttSrv.ListMyRegistrationIds(ctx) if err != nil { aulogging.WarnErrf(ctx, err, "failed to obtain registrations for currently logged in user: %s", err.Error()) - return 0, common.NewBadGateway(ctx, common.DownstreamAttSrv, common.Details("downstream error when contacting attendee service")) + return attendeeservice.Attendee{}, common.NewBadGateway(ctx, common.DownstreamAttSrv, common.Details("downstream error when contacting attendee service")) } if len(myRegIDs) == 0 { aulogging.InfoErr(ctx, err, "currently logged in user has no registrations - cannot be in a group") - return 0, common.NewForbidden(ctx, common.NoSuchAttendee, common.Details("you do not have a valid registration")) + return attendeeservice.Attendee{}, common.NewForbidden(ctx, common.NoSuchAttendee, common.Details("you do not have a valid registration")) } myID := myRegIDs[0] if err := g.checkAttending(ctx, myID); err != nil { - return 0, err + return attendeeservice.Attendee{}, err } - return myID, nil + + attendee, err := g.AttSrv.GetAttendee(ctx, myID) + if err != nil { + return attendeeservice.Attendee{}, err + } + // ensure ID set in Attendee + attendee.ID = myID + + return attendee, nil } func (g *groupService) checkAttending(ctx context.Context, badgeNo int64) error { @@ -42,7 +50,7 @@ func (g *groupService) checkAttending(ctx context.Context, badgeNo int64) error } } -func maxGroupSize() uint { +func maxGroupSize() int64 { conf, err := config.GetApplicationConfig() if err != nil { panic("configuration not loaded before call to maxGroupSize() - this is a bug") @@ -58,7 +66,7 @@ func allowedFlags() []string { return conf.Service.GroupFlags } -func publicInfo(grp *modelsv1.Group, myID int32) *modelsv1.Group { +func publicInfo(grp *modelsv1.Group, myID int64) *modelsv1.Group { if grp == nil { return nil } @@ -75,7 +83,7 @@ func publicInfo(grp *modelsv1.Group, myID int32) *modelsv1.Group { } } -func maskMembers(members []modelsv1.Member, myID int32) []modelsv1.Member { +func maskMembers(members []modelsv1.Member, myID int64) []modelsv1.Member { result := make([]modelsv1.Member, 0) for _, member := range members { if member.ID == myID { @@ -89,7 +97,7 @@ func maskMembers(members []modelsv1.Member, myID int32) []modelsv1.Member { return result } -func filterInvites(members []modelsv1.Member, myID int32) []modelsv1.Member { +func filterInvites(members []modelsv1.Member, myID int64) []modelsv1.Member { result := make([]modelsv1.Member, 0) for _, member := range members { if member.ID == myID { @@ -101,7 +109,7 @@ func filterInvites(members []modelsv1.Member, myID int32) []modelsv1.Member { return result } -func groupContains(group *modelsv1.Group, memberID int32) bool { +func groupContains(group *modelsv1.Group, memberID int64) bool { if group != nil { for _, member := range group.Members { if member.ID == memberID { @@ -112,7 +120,7 @@ func groupContains(group *modelsv1.Group, memberID int32) bool { return false } -func groupInvited(group *modelsv1.Group, invitedMemberID int32) bool { +func groupInvited(group *modelsv1.Group, invitedMemberID int64) bool { if group != nil { for _, member := range group.Invites { if member.ID == invitedMemberID { diff --git a/test/acceptance/groups_create_test.go b/test/acceptance/groups_create_test.go index 75cfb2d..984899d 100644 --- a/test/acceptance/groups_create_test.go +++ b/test/acceptance/groups_create_test.go @@ -19,7 +19,7 @@ func TestGroupsCreate_UserSuccess(t *testing.T) { defer tstShutdown() docs.Given("Given an authorized user with an active registration who is not in any group") - attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved) + attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved, "Squirrel", "nobody@example.com") token := tstValidUserToken(t, 101) docs.When("When they create a room group with valid data") @@ -40,9 +40,10 @@ func TestGroupsCreate_UserSuccess(t *testing.T) { require.Equal(t, groupSent.Name, groupReadAgain.Name) docs.Then("And it contains exactly the user as owner and no invites") - require.Equal(t, int32(42), groupReadAgain.Owner) + require.Equal(t, int64(42), groupReadAgain.Owner) require.Equal(t, 1, len(groupReadAgain.Members)) - require.Equal(t, int32(42), groupReadAgain.Members[0].ID) + require.Equal(t, int64(42), groupReadAgain.Members[0].ID) + require.Equal(t, "Squirrel", groupReadAgain.Members[0].Nickname) require.Equal(t, 0, len(groupReadAgain.Invites)) } @@ -54,7 +55,7 @@ func TestGroupsCreate_AdminSuccess(t *testing.T) { token := tstValidAdminToken(t) docs.Given("And a registered attendee with an active registration who is not in any group") - attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved) + attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved, "Squirrel", "squirrel@example.com") docs.When("When the admin creates a room group with that attendee as owner") groupSent := v1.GroupCreate{ @@ -74,9 +75,9 @@ func TestGroupsCreate_AdminSuccess(t *testing.T) { require.Equal(t, groupSent.Name, groupReadAgain.Name) docs.Then("And it contains exactly the attendee as owner and no invites") - require.Equal(t, int32(42), groupReadAgain.Owner) + require.Equal(t, int64(42), groupReadAgain.Owner) require.Equal(t, 1, len(groupReadAgain.Members)) - require.Equal(t, int32(42), groupReadAgain.Members[0].ID) + require.Equal(t, int64(42), groupReadAgain.Members[0].ID) require.Equal(t, 0, len(groupReadAgain.Invites)) } @@ -87,7 +88,7 @@ func TestGroupsCreate_AnonymousDeny(t *testing.T) { docs.Given("Given an unauthenticated user") token := tstNoToken() - docs.When("When they attempt create a room group") + docs.When("When they attempt to create a room group") groupSent := v1.GroupCreate{ Name: "kittens", Flags: []string{"public"}, @@ -104,11 +105,11 @@ func TestGroupsCreate_CrossUserDeny(t *testing.T) { defer tstShutdown() docs.Given("Given an authorized non-admin user with an active registration") - attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved) + attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved, "Squirrel", "squirrel@example.com") token := tstValidUserToken(t, 101) docs.Given("Given another user with an active registration who is not in any group") - attMock.SetupRegistered("1234567890", 43, attendeeservice.StatusApproved) + attMock.SetupRegistered("1234567890", 43, attendeeservice.StatusApproved, "Snep", "snep@example.com") docs.When("When the non-admin user tries to create a room group with a different owner than themselves") groupSent := v1.GroupCreate{ @@ -128,9 +129,9 @@ func TestGroupsCreate_CrossUserDeny(t *testing.T) { require.Equal(t, groupSent.Name, groupReadAgain.Name) docs.Then("And it contains exactly the non-admin attendee as owner and no invites") - require.Equal(t, int32(42), groupReadAgain.Owner) + require.Equal(t, int64(42), groupReadAgain.Owner) require.Equal(t, 1, len(groupReadAgain.Members)) - require.Equal(t, int32(42), groupReadAgain.Members[0].ID) + require.Equal(t, int64(42), groupReadAgain.Members[0].ID) require.Equal(t, 0, len(groupReadAgain.Invites)) } @@ -159,7 +160,7 @@ func TestGroupsCreate_UserNonAttendingReg(t *testing.T) { defer tstShutdown() docs.Given("Given an authorized user with a registration in non-attending status") - attMock.SetupRegistered("101", 42, attendeeservice.StatusCancelled) + attMock.SetupRegistered("101", 42, attendeeservice.StatusCancelled, "Squirrel", "squirrel@example.com") token := tstValidUserToken(t, 101) docs.When("When they try to create a room group") @@ -180,14 +181,14 @@ func TestGroupsCreate_InvalidJSONSyntax(t *testing.T) { defer tstShutdown() docs.Given("Given an authorized user with a registration in non-attending status") - attMock.SetupRegistered("101", 42, attendeeservice.StatusCancelled) + attMock.SetupRegistered("101", 42, attendeeservice.StatusCancelled, "Squirrel", "squirrel@example.com") token := tstValidUserToken(t, 101) docs.When("When they try to create a room group, but supply syntactically invalid JSON") response := tstPerformPost("/api/rest/v1/groups", `{"name":"invalid":"extra"`, token) docs.Then("Then the request fails with the expected error") - tstRequireErrorResponse(t, response, http.StatusBadRequest, "group.data.invalid", "please check if your provided JSON is valid") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "group.data.invalid", "invalid json provided") } func TestGroupsCreate_InvalidData(t *testing.T) { @@ -195,7 +196,7 @@ func TestGroupsCreate_InvalidData(t *testing.T) { defer tstShutdown() docs.Given("Given an authorized user with a registration in attending status") - attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved) + attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved, "Squirrel", "squirrel@example.com") token := tstValidUserToken(t, 101) docs.When("When they try to create a room group, but supply invalid information") diff --git a/test/acceptance/groups_delete_test.go b/test/acceptance/groups_delete_test.go new file mode 100644 index 0000000..f42678a --- /dev/null +++ b/test/acceptance/groups_delete_test.go @@ -0,0 +1,128 @@ +package acceptance + +import ( + "github.com/eurofurence/reg-room-service/docs" + "github.com/stretchr/testify/require" + "net/http" + "path" + "testing" +) + +func TestGroupsDelete_AdminSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an attendee with an active registration who is in a group with two members") + id1 := setupExistingGroup(t, "kittens", true, "101", "202") + + docs.Given("Given an authorized admin (a different user)") + token := tstValidAdminToken(t) + + docs.When("When they delete the group") + response := tstPerformDelete(path.Join("/api/rest/v1/groups/", id1), token) + + docs.Then("Then the group is successfully deleted and all its members are now no longer in a group") + require.Equal(t, http.StatusNoContent, response.status, "unexpected http response status") + + user101group := tstPerformGet("/api/rest/v1/groups/my", tstValidUserToken(t, 101)) + require.Equal(t, http.StatusNotFound, user101group.status, "unexpected http response status") + user202group := tstPerformGet("/api/rest/v1/groups/my", tstValidUserToken(t, 202)) + require.Equal(t, http.StatusNotFound, user202group.status, "unexpected http response status") +} + +func TestGroupsDelete_AnonymousDeny(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an attendee with an active registration who is in a group") + id1 := setupExistingGroup(t, "kittens", true, "101") + + docs.Given("Given an unauthenticated user") + token := tstNoToken() + + docs.When("When they try to delete the group") + response := tstPerformDelete(path.Join("/api/rest/v1/groups/", id1), token) + + docs.Then("Then the request is denied with the appropriate error") + tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation") +} + +func TestGroupsDelete_UserNotOwnerDeny(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an attendee with an active registration who is in a group, but NOT its owner") + id1 := setupExistingGroup(t, "kittens", false, "101", "202") + token := tstValidUserToken(t, 202) + + docs.When("When they attempt to delete the group") + response := tstPerformDelete(path.Join("/api/rest/v1/groups/", id1), token) + + docs.Then("Then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusForbidden, "auth.forbidden", "only the group owner or an admin can delete a group") +} + +func TestGroupsDelete_UserNotMemberDeny(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an attendee with an active registration who is in a group") + id1 := setupExistingGroup(t, "kittens", false, "101") + _ = setupExistingGroup(t, "kittens", false, "202") + token := tstValidUserToken(t, 202) + + docs.When("When they attempt to delete a different group they are not a member of") + response := tstPerformDelete(path.Join("/api/rest/v1/groups/", id1), token) + + docs.Then("Then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusForbidden, "auth.forbidden", "only the group owner or an admin can delete a group") +} + +func TestGroupsDelete_UserOwnerSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with an active registration who is the owner of a group") + id1 := setupExistingGroup(t, "kittens", true, "101") + token := tstValidUserToken(t, 101) + + docs.When("When they delete the group") + response := tstPerformDelete(path.Join("/api/rest/v1/groups/", id1), token) + + docs.Then("Then the request is successful and the group has been deleted") + require.Equal(t, http.StatusNoContent, response.status, "unexpected http response status") + + deletedResponse := tstPerformGet(path.Join("/api/rest/v1/groups/", id1), tstValidAdminToken(t)) + require.Equal(t, http.StatusNotFound, deletedResponse.status, "group was not correctly deleted") +} + +func TestGroupsDelete_InvalidID(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with an active registration") + _ = setupExistingGroup(t, "kittens", true, "101") + token := tstValidUserToken(t, 101) + + docs.When("When they try to delete a group, but specify an invalid id") + response := tstPerformDelete("/api/rest/v1/groups/kittycats", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "group.id.invalid", "'kittycats' is not a valid UUID") +} + +func TestGroupsDelete_NotFound(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with an active registration") + registerSubject("101") + token := tstValidUserToken(t, 101) + + wrongId := "7ec0c20c-7dd4-491c-9b52-025be6950cdd" + docs.When("When they try to delete a group, but specify a valid id for a group that does not exist") + response := tstPerformDelete(path.Join("/api/rest/v1/groups/", wrongId), token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusNotFound, "group.id.notfound", "group does not exist") +} diff --git a/test/acceptance/groups_get_test.go b/test/acceptance/groups_get_test.go new file mode 100644 index 0000000..8e49a15 --- /dev/null +++ b/test/acceptance/groups_get_test.go @@ -0,0 +1,176 @@ +package acceptance + +import ( + modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" + "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" + "net/http" + "testing" + + "github.com/eurofurence/reg-room-service/docs" +) + +func TestGroupsGet_AdminSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized admin") + token := tstValidAdminToken(t) + + docs.Given("And a registered attendee with an active registration who is in a group") + id1 := setupExistingGroup(t, "kittens", false, "101") + + docs.When("When the admin requests the group information") + response := tstPerformGet("/api/rest/v1/groups/"+id1, token) + + docs.Then("Then the response is as expected and includes all information") + actual := modelsv1.Group{} + tstRequireSuccessResponse(t, response, http.StatusOK, &actual) + expected := modelsv1.Group{ + ID: id1, + Name: "kittens", + Flags: []string{}, + Comments: p("A nice comment for kittens"), + MaximumSize: 6, + Owner: 42, + Members: []modelsv1.Member{ + { + ID: 42, + Nickname: "Squirrel", + }, + }, + Invites: nil, + } + tstEqualResponseBodies(t, expected, actual) +} + +func TestGroupsGet_UserSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with an active registration who is in a group") + id1 := setupExistingGroup(t, "kittens", true, "101") + token := tstValidUserToken(t, 101) + + docs.When("When they request the group information") + response := tstPerformGet("/api/rest/v1/groups/"+id1, token) + + docs.Then("Then the response is as expected and includes all user visible fields") + actual := modelsv1.Group{} + tstRequireSuccessResponse(t, response, http.StatusOK, &actual) + expected := modelsv1.Group{ + ID: id1, + Name: "kittens", + Flags: []string{"public"}, + Comments: p("A nice comment for kittens"), + MaximumSize: 6, + Owner: 42, + Members: []modelsv1.Member{ + { + ID: 42, + Nickname: "Squirrel", + }, + }, + } + tstEqualResponseBodies(t, expected, actual) +} + +func TestGroupsGet_AnonymousDeny(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a group exists") + id1 := setupExistingGroup(t, "kittens", true, "101") + + docs.Given("Given an unauthenticated user") + token := tstNoToken() + + docs.When("When they attempt to access the group information") + response := tstPerformGet("/api/rest/v1/groups/"+id1, token) + + docs.Then("Then the request is denied") + tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation") +} + +func TestGroupsGet_UserNotMemberAllow(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given someone with an active registration who is in a non-public group") + id1 := setupExistingGroup(t, "kittens", false, "101") + + docs.Given("Given another user with an active registration who is not in the group but knows the secret group id") + attMock.SetupRegistered("1234567890", 43, attendeeservice.StatusApproved, "Panther", "panther@example.com") + token := tstValidUserToken(t, 1234567890) + + docs.When("When they attempt to access the group information") + response := tstPerformGet("/api/rest/v1/groups/"+id1, token) + + docs.Then("Then the request is successful and the user visible information is returned") + actual := modelsv1.Group{} + tstRequireSuccessResponse(t, response, http.StatusOK, &actual) + expected := modelsv1.Group{ + ID: id1, + Name: "kittens", + Flags: []string{}, + Comments: p("A nice comment for kittens"), + MaximumSize: 6, + Owner: 42, + Members: []modelsv1.Member{ + { + ID: 42, + Nickname: "Squirrel", + }, + }, + } + tstEqualResponseBodies(t, expected, actual) +} + +func TestGroupsGet_UserNoReg(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given someone with an active registration who is in a group") + id1 := setupExistingGroup(t, "kittens", true, "101") + + docs.Given("Given another authorized non-admin user with NO registration") + token := tstValidUserToken(t, 1234567890) + + docs.When("When they attempt to access the group information") + response := tstPerformGet("/api/rest/v1/groups/"+id1, token) + + docs.Then("Then the request is denied with the appropriate error") + tstRequireErrorResponse(t, response, http.StatusForbidden, "attendee.notfound", "you do not have a valid registration") +} + +func TestGroupsGet_UserNonAttendingReg(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given someone with an active registration who is in a group") + id1 := setupExistingGroup(t, "kittens", true, "101") + + docs.Given("Given another authorized user with a registration in non-attending status") + attMock.SetupRegistered("202", 43, attendeeservice.StatusWaiting, "Snep", "snep@example.com") + token := tstValidUserToken(t, 202) + + docs.When("When they attempt to access the group information") + response := tstPerformGet("/api/rest/v1/groups/"+id1, token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusForbidden, "attendee.status.not.attending", "registration is not in attending status") +} + +func TestGroupsGet_InvalidID(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with a registration in attending status") + attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved, "Squirrel", "squirrel@example.com") + token := tstValidUserToken(t, 101) + + docs.When("When they attempt to access group information, but supply an invalid id") + response := tstPerformGet("/api/rest/v1/groups/kittycats", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "group.id.invalid", "you must specify a valid uuid") +} diff --git a/test/acceptance/groups_list_test.go b/test/acceptance/groups_list_test.go index e2aa67a..4c35319 100644 --- a/test/acceptance/groups_list_test.go +++ b/test/acceptance/groups_list_test.go @@ -2,11 +2,11 @@ package acceptance import ( modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" + "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" "net/http" + "net/url" "testing" - "github.com/stretchr/testify/require" - "github.com/eurofurence/reg-room-service/docs" ) @@ -27,6 +27,60 @@ func TestGroupsList_AdminSuccess(t *testing.T) { docs.Then("Then the request is successful and the response includes all group information") actual := modelsv1.GroupList{} tstRequireSuccessResponse(t, response, http.StatusOK, &actual) + grp1 := modelsv1.Group{ + ID: id1, + Name: "kittens", + Flags: []string{"public"}, + Comments: p("A nice comment for kittens"), + MaximumSize: 6, + Owner: 42, + Members: []modelsv1.Member{ + { + ID: 42, + Nickname: "Squirrel", + }, + }, + Invites: nil, + } + grp2 := modelsv1.Group{ + ID: id2, + Name: "puppies", + Flags: []string{}, + Comments: p("A nice comment for puppies"), + MaximumSize: 6, + Owner: 43, + Members: []modelsv1.Member{ + { + ID: 43, + Nickname: "Snep", + }, + }, + Invites: nil, + } + expected := modelsv1.GroupList{} + if id1 < id2 { + expected.Groups = append(expected.Groups, &grp1, &grp2) + } else { + expected.Groups = append(expected.Groups, &grp2, &grp1) + } + tstEqualResponseBodies(t, expected, actual) +} + +func TestGroupsList_AdminSuccess_Filtered(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given two registered attendees with an active registration who are in a group each") + id1 := setupExistingGroup(t, "kittens", true, "101") + _ = setupExistingGroup(t, "puppies", false, "202") + + docs.When("When an admin requests to list groups containing a certain attendee") + token := tstValidAdminToken(t) + response := tstPerformGet("/api/rest/v1/groups?member_ids=42", token) + + docs.Then("Then the request is successful and the response includes the requested group information") + actual := modelsv1.GroupList{} + tstRequireSuccessResponse(t, response, http.StatusOK, &actual) expected := modelsv1.GroupList{ Groups: []*modelsv1.Group{ { @@ -34,32 +88,130 @@ func TestGroupsList_AdminSuccess(t *testing.T) { Name: "kittens", Flags: []string{"public"}, Comments: p("A nice comment for kittens"), - MaximumSize: p(int32(6)), + MaximumSize: 6, Owner: 42, Members: []modelsv1.Member{ { ID: 42, - Nickname: "", + Nickname: "Squirrel", }, }, Invites: nil, }, + }, + } + tstEqualResponseBodies(t, expected, actual) +} + +func TestGroupsList_UserSuccess_Public(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a public group and a registered attendee who is not in the group") + id1 := setupExistingGroup(t, "kittens", true, "101") + _ = registerSubject("202") + + docs.When("When the attendee not in the group requests to list groups") + token := tstValidUserToken(t, 202) + response := tstPerformGet("/api/rest/v1/groups", token) + + docs.Then("Then the request is successful and the response includes only public information about public groups") + actual := modelsv1.GroupList{} + tstRequireSuccessResponse(t, response, http.StatusOK, &actual) + expected := modelsv1.GroupList{ + Groups: []*modelsv1.Group{ + // Owner and badge numbers are omitted! { - ID: id2, - Name: "puppies", - Flags: []string{}, - Comments: p("A nice comment for puppies"), - MaximumSize: p(int32(6)), - Owner: 43, + ID: id1, + Name: "kittens", + Flags: []string{"public"}, + MaximumSize: 6, Members: []modelsv1.Member{ { - ID: 43, Nickname: "", }, }, - Invites: nil, }, }, } - require.EqualValues(t, expected, actual, "unexpected differences in response body") + tstEqualResponseBodies(t, expected, actual) +} + +func TestGroupsList_UserSuccess_NonPublic(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a private group and a registered attendee who is not in the group") + _ = setupExistingGroup(t, "puppies", false, "101") + _ = registerSubject("202") + + docs.When("When the attendee not in the group requests to list groups") + token := tstValidUserToken(t, 202) + response := tstPerformGet("/api/rest/v1/groups", token) + + docs.Then("Then the request is successful but the response does not include the group") + actual := modelsv1.GroupList{} + tstRequireSuccessResponse(t, response, http.StatusOK, &actual) + expected := modelsv1.GroupList{ + Groups: []*modelsv1.Group{}, + } + tstEqualResponseBodies(t, expected, actual) +} + +func TestGroupsList_AnonymousDeny(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an unauthenticated user") + token := tstNoToken() + + docs.When("When they attempt to list groups") + response := tstPerformGet("/api/rest/v1/groups", token) + + docs.Then("Then the request is denied") + tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation") +} + +func TestGroupsList_UserNoReg(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with NO registration") + token := tstValidUserToken(t, 101) + + docs.When("When they try to list groups") + response := tstPerformGet("/api/rest/v1/groups", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusForbidden, "attendee.notfound", "you do not have a valid registration") +} + +func TestGroupsList_UserNonAttendingReg(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with a registration in non-attending status") + attMock.SetupRegistered("101", 42, attendeeservice.StatusNew, "Squirrel", "squirrel@example.com") + token := tstValidUserToken(t, 101) + + docs.When("When they try to list groups") + response := tstPerformGet("/api/rest/v1/groups", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusForbidden, "attendee.status.not.attending", "registration is not in attending status") +} + +func TestGroupsCreate_InvalidQueryParams(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with a registration in attending status") + attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved, "Squirrel", "squirrel@example.com") + token := tstValidUserToken(t, 101) + + docs.When("When they try to list groups, but supply invalid parameters") + response := tstPerformGet("/api/rest/v1/groups?member_ids=kittycat,-999", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "request.parse.failed", url.Values{"details": []string{"member ids must be numeric and valid. Invalid member id: kittycat"}}) } diff --git a/test/acceptance/groups_member_add_test.go b/test/acceptance/groups_member_add_test.go index 979fbad..9953051 100644 --- a/test/acceptance/groups_member_add_test.go +++ b/test/acceptance/groups_member_add_test.go @@ -3,12 +3,12 @@ package acceptance import ( "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" "net/http" + "path" "testing" "github.com/stretchr/testify/require" "github.com/eurofurence/reg-room-service/docs" - modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" ) func TestGroupsAddMember_OwnerFirstSuccess(t *testing.T) { @@ -16,21 +16,15 @@ func TestGroupsAddMember_OwnerFirstSuccess(t *testing.T) { defer tstShutdown() docs.Given("Given an authorized user with an active registration who is owner of a group") - attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved) + id1 := setupExistingGroup(t, "kittens", true, "101") + location := path.Join("/api/rest/v1/groups/", id1) token := tstValidUserToken(t, 101) - groupSent := modelsv1.GroupCreate{ - Name: "kittens", - Flags: []string{"public"}, - Comments: p("A nice comment"), - Owner: 0, // myself - } - group := tstPerformPost("/api/rest/v1/groups", tstRenderJson(groupSent), token) docs.Given("Given another attendee with an active registration who is not in any group") - attMock.SetupRegistered("1234567890", 84, attendeeservice.StatusApproved) + attMock.SetupRegistered("1234567890", 84, attendeeservice.StatusApproved, "Panther", "panther@example.com") docs.When("When the group owner requests an invite for the attendee") - response := tstPerformPostNoBody(group.location+"/members/84", token) + response := tstPerformPostNoBody(location+"/members/84", token) docs.Then("Then an invitation is successfully created") require.Equal(t, http.StatusNoContent, response.status, "unexpected http response status") diff --git a/test/acceptance/groups_my_test.go b/test/acceptance/groups_my_test.go index 45115ec..e9e24c3 100644 --- a/test/acceptance/groups_my_test.go +++ b/test/acceptance/groups_my_test.go @@ -32,12 +32,12 @@ func TestGroupsMy_UserSuccess(t *testing.T) { Name: "kittens", Flags: []string{"public"}, Comments: p("A nice comment for kittens"), - MaximumSize: p(int32(6)), + MaximumSize: 6, Owner: 42, Members: []modelsv1.Member{ { ID: 42, - Nickname: "", + Nickname: "Squirrel", }, }, Invites: nil, @@ -78,7 +78,7 @@ func TestGroupsMy_UserNonAttendingReg(t *testing.T) { defer tstShutdown() docs.Given("Given an authorized user with a registration in non-attending status") - attMock.SetupRegistered("101", 42, attendeeservice.StatusNew) + attMock.SetupRegistered("101", 42, attendeeservice.StatusNew, "Squirrel", "squirrel@example.com") token := tstValidUserToken(t, 101) docs.When("When they request their group") @@ -93,7 +93,7 @@ func TestGroupsMy_UserNoGroup(t *testing.T) { defer tstShutdown() docs.Given("Given an authorized user with a registration in attending status") - attMock.SetupRegistered("101", 42, attendeeservice.StatusPartiallyPaid) + attMock.SetupRegistered("101", 42, attendeeservice.StatusPartiallyPaid, "Squirrel", "squirrel@example.com") token := tstValidUserToken(t, 101) docs.Given("Given they are not in any group") diff --git a/test/acceptance/groups_update_test.go b/test/acceptance/groups_update_test.go index 3198d50..2cbd153 100644 --- a/test/acceptance/groups_update_test.go +++ b/test/acceptance/groups_update_test.go @@ -2,39 +2,31 @@ package acceptance import ( "github.com/eurofurence/reg-room-service/docs" - v1 "github.com/eurofurence/reg-room-service/internal/api/v1" - "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" "github.com/stretchr/testify/require" "net/http" + "net/url" "path" "testing" ) -func TestGroupsUpdate_UserSuccess(t *testing.T) { +func TestGroupsUpdate_AdminSuccess(t *testing.T) { tstSetup(tstDefaultConfigFileRoomGroups) defer tstShutdown() - docs.Given("Given an authorized user with an active registration who is the owner of a group") - token := tstValidUserToken(t, 101) - attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved) - - groupSent := v1.GroupCreate{ - Name: "kittens", - Flags: []string{"public"}, - Comments: p("A nice comment"), - Owner: 0, // myself - } + docs.Given("Given an attendee with an active registration who is in a group") + id1 := setupExistingGroup(t, "kittens", true, "101") - response := tstPerformPost("/api/rest/v1/groups", tstRenderJson(groupSent), token) + docs.Given("Given an authorized admin (a different user)") + token := tstValidAdminToken(t) - docs.When("When they retrieve the group and update the name of a group") - getGroup := tstReadGroup(t, response.location) + docs.When("When they retrieve the group and update its name") + getGroup := tstReadGroup(t, path.Join("/api/rest/v1/groups/", id1)) savedID := getGroup.ID getGroup.Name = "dogs" - response = tstPerformPut(path.Join("/api/rest/v1/groups/", getGroup.ID), tstRenderJson(getGroup), token) - docs.Then("Then the group should be successfully updated") + response := tstPerformPut(path.Join("/api/rest/v1/groups/", getGroup.ID), tstRenderJson(getGroup), token) + docs.Then("Then the group is successfully updated") require.Equal(t, http.StatusOK, response.status, "unexpected http response status") require.Regexp(t, validGroupLocationRegex, response.location, "invalid location header in response") @@ -44,34 +36,69 @@ func TestGroupsUpdate_UserSuccess(t *testing.T) { require.Len(t, getGroup.Flags, 1) } -func TestGroupsUpdate_AdminSuccess(t *testing.T) { +func TestGroupsUpdate_AnonymousDeny(t *testing.T) { tstSetup(tstDefaultConfigFileRoomGroups) defer tstShutdown() - docs.Given("Given an authorized admin") - token := tstValidAdminToken(t) + docs.Given("Given an attendee with an active registration who is in a group") + id1 := setupExistingGroup(t, "kittens", true, "101") + location := path.Join("/api/rest/v1/groups/", id1) - docs.Given("Given an attendee with an active registration") - attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved) + docs.Given("Given an unauthenticated user") + token := tstNoToken() - docs.When("When the admin creates a room group with that attendee as owner") - groupSent := v1.GroupCreate{ - Name: "kittens", - Flags: []string{"public"}, - Comments: p("A nice comment"), - Owner: 42, - } + docs.When("When they try to update the group") + getGroup := tstReadGroup(t, location) + getGroup.Name = "dogs" + response := tstPerformPut(path.Join("/api/rest/v1/groups/", getGroup.ID), tstRenderJson(getGroup), token) + + docs.Then("Then the request should be denied") + tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation") - response := tstPerformPost("/api/rest/v1/groups", tstRenderJson(groupSent), token) + docs.Then("And the group is unchanged") + getGroupAgain := tstReadGroup(t, location) + require.Equal(t, "kittens", getGroupAgain.Name) +} + +func TestGroupsUpdate_UserDeny(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an attendee with an active registration who is in a group, but NOT its owner") + id1 := setupExistingGroup(t, "kittens", true, "101", "202") + location := path.Join("/api/rest/v1/groups/", id1) + token := tstValidUserToken(t, 202) + + docs.When("When they attempt to update the name of the group") + getGroup := tstReadGroup(t, location) + getGroup.Name = "dogs" + + response := tstPerformPut(path.Join("/api/rest/v1/groups/", getGroup.ID), tstRenderJson(getGroup), token) + + docs.Then("Then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusForbidden, "auth.forbidden", "only the group owner or an admin can change a group") + + docs.Then("And the group is unchanged") + getGroupAgain := tstReadGroup(t, location) + require.Equal(t, "kittens", getGroupAgain.Name) +} + +func TestGroupsUpdate_UserOwnerSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with an active registration who is the owner of a group") + id1 := setupExistingGroup(t, "kittens", true, "101") + token := tstValidUserToken(t, 101) - docs.When("When they retrieve the group and update the name of a group") - getGroup := tstReadGroup(t, response.location) + docs.When("When they retrieve the group and update its name") + getGroup := tstReadGroup(t, path.Join("/api/rest/v1/groups/", id1)) savedID := getGroup.ID getGroup.Name = "dogs" - response = tstPerformPut(path.Join("/api/rest/v1/groups/", getGroup.ID), tstRenderJson(getGroup), token) - docs.Then("Then the group should be successfully updated") + response := tstPerformPut(path.Join("/api/rest/v1/groups/", getGroup.ID), tstRenderJson(getGroup), token) + docs.Then("Then the group is successfully updated") require.Equal(t, http.StatusOK, response.status, "unexpected http response status") require.Regexp(t, validGroupLocationRegex, response.location, "invalid location header in response") @@ -81,32 +108,71 @@ func TestGroupsUpdate_AdminSuccess(t *testing.T) { require.Len(t, getGroup.Flags, 1) } -func TestGroupsUpdate_AnonymousDeny(t *testing.T) { +func TestGroupsUpdate_InvalidJSONSyntax(t *testing.T) { tstSetup(tstDefaultConfigFileRoomGroups) defer tstShutdown() - docs.Given("Given an existing group that was created by an authenticated user") - attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved) - authenticatedToken := tstValidUserToken(t, 101) + docs.Given("Given an authorized user with an active registration who is the owner of a group") + id1 := setupExistingGroup(t, "kittens", true, "101") + token := tstValidUserToken(t, 101) - groupSent := v1.GroupCreate{ - Name: "kittens", - Flags: []string{"public"}, - Comments: p("A nice comment"), - Owner: 42, - } + docs.When("When they try to update the room group, but supply syntactically invalid JSON") + response := tstPerformPut(path.Join("/api/rest/v1/groups/", id1), `{"name":"invalid":"extra"`, token) - response := tstPerformPost("/api/rest/v1/groups", tstRenderJson(groupSent), authenticatedToken) + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "group.data.invalid", "invalid json provided") +} - docs.Given("Given an unauthenticated user") - token := tstNoToken() +func TestGroupsUpdate_InvalidData(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() - docs.When("When they try to update the group") - getGroup := tstReadGroup(t, response.location) + docs.Given("Given an authorized user with an active registration who is the owner of a group") + id1 := setupExistingGroup(t, "kittens", true, "101") + token := tstValidUserToken(t, 101) - getGroup.Name = "dogs" - response = tstPerformPut(path.Join("/api/rest/v1/groups/", getGroup.ID), tstRenderJson(getGroup), token) + docs.When("When they try to update the group but supply invalid information") + getGroup := tstReadGroup(t, path.Join("/api/rest/v1/groups/", id1)) + getGroup.Flags = []string{"invalid"} + getGroup.Name = "" + response := tstPerformPut(path.Join("/api/rest/v1/groups/", getGroup.ID), tstRenderJson(getGroup), token) - docs.Then("Then the request should be denied") - tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation") + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "group.data.invalid", url.Values{"name": []string{"group name cannot be empty"}, "flags": []string{"no such flag 'invalid'"}}) +} + +func TestGroupsUpdate_InvalidID(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with an active registration") + id1 := setupExistingGroup(t, "kittens", true, "101") + token := tstValidUserToken(t, 101) + + docs.When("When they try to update the group but supply invalid information") + getGroup := tstReadGroup(t, path.Join("/api/rest/v1/groups/", id1)) + response := tstPerformPut("/api/rest/v1/groups/kittycats", tstRenderJson(getGroup), token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "group.id.invalid", "'kittycats' is not a valid UUID") +} + +func TestGroupsUpdate_NotFound(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with an active registration") + id1 := setupExistingGroup(t, "kittens", true, "101") + token := tstValidUserToken(t, 101) + + wrongId := "7ec0c20c-7dd4-491c-9b52-025be6950cdd" + if wrongId == id1 { + wrongId = "7ec0c20c-7dd4-491c-9b52-025be6950cef" + } + docs.When("When they try to update a group, but specify a valid id for a group that does not exist") + getGroup := tstReadGroup(t, path.Join("/api/rest/v1/groups/", id1)) + response := tstPerformPut(path.Join("/api/rest/v1/groups/", wrongId), tstRenderJson(getGroup), token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusNotFound, "group.id.notfound", "group does not exist") } diff --git a/test/acceptance/tokens_test.go b/test/acceptance/tokens_test.go index 1a4f485..b426e96 100644 --- a/test/acceptance/tokens_test.go +++ b/test/acceptance/tokens_test.go @@ -20,6 +20,8 @@ const valid_JWT_is_admin_sub1234567890 = `eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.e func tstValidUserToken(t *testing.T, id uint) string { if id == 101 { return valid_JWT_is_registered_sub101 + } else if id == 202 { + return valid_JWT_is_staff_sub202 } else { return valid_JWT_is_not_registered_sub1234567890 } diff --git a/test/acceptance/utils_test.go b/test/acceptance/utils_test.go index 7fae447..2305cc5 100644 --- a/test/acceptance/utils_test.go +++ b/test/acceptance/utils_test.go @@ -2,8 +2,10 @@ package acceptance import ( "encoding/json" + "fmt" "github.com/eurofurence/reg-room-service/internal/application/web" "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" + "gopkg.in/yaml.v3" "io/ioutil" "log" "net/http" @@ -35,22 +37,28 @@ func setupExistingGroup(t *testing.T, name string, public bool, subject string, require.Equal(t, http.StatusCreated, response.status, "unexpected http response status") require.Regexp(t, validGroupLocationRegex, response.location, "invalid location header in response") + for _, addSubject := range additionalMemberSubjects { + addBadgeNo := registerSubject(addSubject) + addResponse := tstPerformPostNoBody(fmt.Sprintf("%s/members/%d", response.location, addBadgeNo), tstValidAdminToken(t)) + require.Equal(t, http.StatusNoContent, addResponse.status, "unexpected http response status") + } + locs := strings.Split(response.location, "/") return locs[len(locs)-1] } -func registerSubject(subject string) int32 { +func registerSubject(subject string) int64 { switch subject { case "101": - attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved) + attMock.SetupRegistered("101", 42, attendeeservice.StatusApproved, "Squirrel", "squirrel@example.com") return 42 case "202": - attMock.SetupRegistered("202", 43, attendeeservice.StatusPaid) + attMock.SetupRegistered("202", 43, attendeeservice.StatusPaid, "Snep", "snep@example.com") return 43 default: - attMock.SetupRegistered("1234567890", 99, attendeeservice.StatusCancelled) + attMock.SetupRegistered("1234567890", 99, attendeeservice.StatusCancelled, "Panther", "panther@example.com") return 99 } } @@ -235,3 +243,16 @@ func tstRequireSuccessResponse(t *testing.T, response tstWebResponse, expectedSt require.Equal(t, expectedStatus, response.status, "unexpected http response status") tstParseJson(response.body, resultBodyPtr) } + +func tstEqualResponseBodies(t *testing.T, expected interface{}, actual interface{}) { + // render both values to yaml and then compare - this gives easiest to debug differences + expectedYaml, err := yaml.Marshal(expected) + if err != nil { + t.Errorf("failed to marshal expected body to yaml: %s", err) + } + actualYaml, err := yaml.Marshal(actual) + if err != nil { + t.Errorf("failed to marshal actual body to yaml: %s", err) + } + require.Equal(t, string(expectedYaml), string(actualYaml)) +}