diff --git a/internal/http/middleware.go b/internal/http/middleware.go index 072bd35a..d801bf37 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -330,7 +330,7 @@ func (s *Service) departmentUserOnly(h http.HandlerFunc) http.HandlerFunc { if UserType != thunderdome.AdminUserType { var UserErr error OrgRole, DepartmentRole, UserErr = s.OrganizationDataSvc.DepartmentUserRole(ctx, UserID, OrgID, DepartmentID) - if UserErr != nil { + if UserErr != nil || (DepartmentRole == "" && OrgRole != thunderdome.AdminUserType) { s.Logger.Ctx(ctx).Warn("middleware departmentUserOnly REQUIRES_DEPARTMENT_USER", zap.Error(UserErr), zap.String("user_id", UserID), @@ -375,7 +375,7 @@ func (s *Service) departmentAdminOnly(h http.HandlerFunc) http.HandlerFunc { var DepartmentRole string if UserType != thunderdome.AdminUserType { var UserErr error - OrgRole, DepartmentRole, UserErr := s.OrganizationDataSvc.DepartmentUserRole(ctx, UserID, OrgID, DepartmentID) + OrgRole, DepartmentRole, UserErr = s.OrganizationDataSvc.DepartmentUserRole(ctx, UserID, OrgID, DepartmentID) if UserErr != nil { s.Failure(w, r, http.StatusForbidden, Errorf(EUNAUTHORIZED, "REQUIRES_DEPARTMENT_USER")) return diff --git a/internal/http/middleware_test.go b/internal/http/middleware_test.go index cdccbcfc..a34c6f1c 100644 --- a/internal/http/middleware_test.go +++ b/internal/http/middleware_test.go @@ -150,9 +150,9 @@ func (m *MockTeamDataSvc) TeamList(ctx context.Context, Limit int, Offset int) ( panic("implement me") } -func (m *MockTeamDataSvc) TeamIsSubscribed(ctx context.Context, TeamID string) (bool, error) { - //TODO implement me - panic("implement me") +func (m *MockTeamDataSvc) TeamIsSubscribed(ctx context.Context, teamID string) (bool, error) { + args := m.Called(ctx, teamID) + return args.Bool(0), args.Error(1) } func (m *MockTeamDataSvc) GetTeamMetrics(ctx context.Context, teamID string) (*thunderdome.TeamMetrics, error) { @@ -427,3 +427,696 @@ func TestTeamAdminOnly(t *testing.T) { }) } } + +func TestSubscribedTeamOnly(t *testing.T) { + tests := []struct { + name string + userType string + teamID string + subscriptionsEnabled bool + expectedStatusCode int + setupMocks func(*MockTeamDataSvc) + }{ + { + name: "Admin user bypasses subscription check", + userType: thunderdome.AdminUserType, + teamID: "1353a056-d239-41e1-ad1a-b3f0777e6c3a", + subscriptionsEnabled: true, + expectedStatusCode: http.StatusOK, + }, + { + name: "Subscribed team allowed", + userType: "MEMBER", + teamID: "2d6176c8-50d6-4963-8172-2c20ca5022a3", + subscriptionsEnabled: true, + expectedStatusCode: http.StatusOK, + setupMocks: func(mockTeamDataSvc *MockTeamDataSvc) { + mockTeamDataSvc.On( + "TeamIsSubscribed", + mock.Anything, + "2d6176c8-50d6-4963-8172-2c20ca5022a3", + ).Return(true, nil).Once() + }, + }, + { + name: "Unsubscribed team forbidden", + userType: "MEMBER", + teamID: "128ee064-62ca-43b2-9fca-9c1089c89bd2", + subscriptionsEnabled: true, + expectedStatusCode: http.StatusForbidden, + setupMocks: func(mockTeamDataSvc *MockTeamDataSvc) { + mockTeamDataSvc.On( + "TeamIsSubscribed", + mock.Anything, + "128ee064-62ca-43b2-9fca-9c1089c89bd2", + ).Return(false, nil).Once() + }, + }, + { + name: "Invalid teamID", + userType: "MEMBER", + teamID: "invalid-uuid", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "Subscriptions disabled", + userType: "MEMBER", + teamID: "31c8521e-2e68-4898-b3cf-e919cf80dbe2", + subscriptionsEnabled: false, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockTeamDataSvc := new(MockTeamDataSvc) + mockConfig := &Config{SubscriptionsEnabled: true} + service := &Service{ + TeamDataSvc: mockTeamDataSvc, + Config: mockConfig, + } + + mockConfig.SubscriptionsEnabled = tt.subscriptionsEnabled + + if tt.setupMocks != nil { + tt.setupMocks(mockTeamDataSvc) + } + + handler := service.subscribedTeamOnly(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest("GET", "/teams/"+tt.teamID+"/test", nil) + req = mux.SetURLVars(req, map[string]string{"teamId": tt.teamID}) + req = req.WithContext(context.WithValue(req.Context(), contextKeyUserType, tt.userType)) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code) + + mockTeamDataSvc.AssertExpectations(t) + }) + } +} + +type MockOrganizationDataService struct { + mock.Mock +} + +func (m *MockOrganizationDataService) OrganizationGet(ctx context.Context, OrgID string) (*thunderdome.Organization, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationUserRole(ctx context.Context, UserID string, OrgID string) (string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationListByUser(ctx context.Context, UserID string, Limit int, Offset int) []*thunderdome.UserOrganization { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationCreate(ctx context.Context, UserID string, OrgName string) (*thunderdome.Organization, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationUpdate(ctx context.Context, OrgId string, OrgName string) (*thunderdome.Organization, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationUserList(ctx context.Context, OrgID string, Limit int, Offset int) []*thunderdome.OrganizationUser { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationAddUser(ctx context.Context, OrgID string, UserID string, Role string) (string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationUpsertUser(ctx context.Context, OrgID string, UserID string, Role string) (string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationUpdateUser(ctx context.Context, OrgID string, UserID string, Role string) (string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationRemoveUser(ctx context.Context, OrganizationID string, UserID string) error { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationInviteUser(ctx context.Context, OrgID string, Email string, Role string) (string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationUserGetInviteByID(ctx context.Context, InviteID string) (thunderdome.OrganizationUserInvite, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationDeleteUserInvite(ctx context.Context, InviteID string) error { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationGetUserInvites(ctx context.Context, orgId string) ([]thunderdome.OrganizationUserInvite, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationTeamList(ctx context.Context, OrgID string, Limit int, Offset int) []*thunderdome.Team { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationTeamCreate(ctx context.Context, OrgID string, TeamName string) (*thunderdome.Team, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationTeamUserRole(ctx context.Context, UserID string, OrgID string, TeamID string) (string, string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationDelete(ctx context.Context, OrgID string) error { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationList(ctx context.Context, Limit int, Offset int) []*thunderdome.Organization { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) GetOrganizationMetrics(ctx context.Context, organizationID string) (*thunderdome.OrganizationMetrics, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentUserRole(ctx context.Context, userID, orgID, departmentID string) (string, string, error) { + args := m.Called(ctx, userID, orgID, departmentID) + return args.String(0), args.String(1), args.Error(2) +} + +func (m *MockOrganizationDataService) DepartmentGet(ctx context.Context, DepartmentID string) (*thunderdome.Department, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationDepartmentList(ctx context.Context, OrgID string, Limit int, Offset int) []*thunderdome.Department { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentCreate(ctx context.Context, OrgID string, OrgName string) (*thunderdome.Department, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentUpdate(ctx context.Context, DeptId string, DeptName string) (*thunderdome.Department, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentTeamList(ctx context.Context, DepartmentID string, Limit int, Offset int) []*thunderdome.Team { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentTeamCreate(ctx context.Context, DepartmentID string, TeamName string) (*thunderdome.Team, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentUserList(ctx context.Context, DepartmentID string, Limit int, Offset int) []*thunderdome.DepartmentUser { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentAddUser(ctx context.Context, DepartmentID string, UserID string, Role string) (string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentUpsertUser(ctx context.Context, DepartmentID string, UserID string, Role string) (string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentUpdateUser(ctx context.Context, DepartmentID string, UserID string, Role string) (string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentRemoveUser(ctx context.Context, DepartmentID string, UserID string) error { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentTeamUserRole(ctx context.Context, UserID string, OrgID string, DepartmentID string, TeamID string) (string, string, string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentDelete(ctx context.Context, DepartmentID string) error { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentInviteUser(ctx context.Context, DeptID string, Email string, Role string) (string, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentUserGetInviteByID(ctx context.Context, InviteID string) (thunderdome.DepartmentUserInvite, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentDeleteUserInvite(ctx context.Context, InviteID string) error { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) DepartmentGetUserInvites(ctx context.Context, deptId string) ([]thunderdome.DepartmentUserInvite, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockOrganizationDataService) OrganizationIsSubscribed(ctx context.Context, orgID string) (bool, error) { + args := m.Called(ctx, orgID) + return args.Bool(0), args.Error(1) +} + +func TestSubscribedOrgOnly(t *testing.T) { + tests := []struct { + name string + userType string + orgID string + subscriptionsEnabled bool + isSubscribed bool + expectedStatusCode int + setupMocks func(*MockOrganizationDataService) + }{ + { + name: "Admin user bypasses subscription check", + userType: thunderdome.AdminUserType, + orgID: "1353a056-d239-41e1-ad1a-b3f0777e6c3a", + subscriptionsEnabled: true, + isSubscribed: false, + expectedStatusCode: http.StatusOK, + }, + { + name: "Subscribed organization allowed", + userType: "MEMBER", + orgID: "2d6176c8-50d6-4963-8172-2c20ca5022a3", + subscriptionsEnabled: true, + isSubscribed: true, + expectedStatusCode: http.StatusOK, + setupMocks: func(mockOrgDataSvc *MockOrganizationDataService) { + mockOrgDataSvc.On( + "OrganizationIsSubscribed", + mock.Anything, + "2d6176c8-50d6-4963-8172-2c20ca5022a3", + ).Return(true, nil).Once() + }, + }, + { + name: "Unsubscribed organization forbidden", + userType: "MEMBER", + orgID: "128ee064-62ca-43b2-9fca-9c1089c89bd2", + subscriptionsEnabled: true, + isSubscribed: false, + expectedStatusCode: http.StatusForbidden, + setupMocks: func(mockOrgDataSvc *MockOrganizationDataService) { + mockOrgDataSvc.On( + "OrganizationIsSubscribed", + mock.Anything, + "128ee064-62ca-43b2-9fca-9c1089c89bd2", + ).Return(false, nil).Once() + }, + }, + { + name: "Invalid orgID", + userType: "MEMBER", + orgID: "invalid-uuid", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "Subscriptions disabled", + userType: "MEMBER", + orgID: "31c8521e-2e68-4898-b3cf-e919cf80dbe2", + subscriptionsEnabled: false, + isSubscribed: false, + expectedStatusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockOrgDataSvc := new(MockOrganizationDataService) + mockConfig := &Config{SubscriptionsEnabled: true} + service := &Service{ + OrganizationDataSvc: mockOrgDataSvc, + Config: mockConfig, + } + + mockConfig.SubscriptionsEnabled = tt.subscriptionsEnabled + + if tt.setupMocks != nil { + tt.setupMocks(mockOrgDataSvc) + } + + handler := service.subscribedOrgOnly(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest("GET", "/organizations/"+tt.orgID+"/test", nil) + req = mux.SetURLVars(req, map[string]string{"orgId": tt.orgID}) + req = req.WithContext(context.WithValue(req.Context(), contextKeyUserType, tt.userType)) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code) + + mockOrgDataSvc.AssertExpectations(t) + }) + } +} + +func TestDepartmentAdminOnly(t *testing.T) { + tests := []struct { + name string + userType string + userID string + orgID string + departmentID string + expectedStatusCode int + expectedOrgRole string + expectedDeptRole string + setupMocks func(*MockOrganizationDataService) + }{ + { + name: "Admin user bypasses role check", + userType: thunderdome.AdminUserType, + userID: "002738c2-fcf2-438e-a755-2bf9c4233b74", + orgID: "00280040-11e2-4c00-9cd0-325b4efd7de2", + departmentID: "002a5613-5d31-430d-859f-0e97fee8c5d2", + expectedStatusCode: http.StatusOK, + expectedOrgRole: thunderdome.AdminUserType, + expectedDeptRole: thunderdome.AdminUserType, + }, + { + name: "Department admin allowed", + userType: "USER", + userID: "0023f0d5-19d0-403f-a30d-d5b7616c72bd", + orgID: "00241406-94cb-4fe1-9b4a-e979f5761f10", + departmentID: "0024d5f8-42b1-46c7-b5a7-da97d59d6b36", + expectedStatusCode: http.StatusOK, + expectedOrgRole: "MEMBER", + expectedDeptRole: thunderdome.AdminUserType, + setupMocks: func(mockOrgDataSvc *MockOrganizationDataService) { + mockOrgDataSvc.On( + "DepartmentUserRole", + mock.Anything, + "0023f0d5-19d0-403f-a30d-d5b7616c72bd", + "00241406-94cb-4fe1-9b4a-e979f5761f10", + "0024d5f8-42b1-46c7-b5a7-da97d59d6b36", + ).Return("MEMBER", thunderdome.AdminUserType, nil).Once() + }, + }, + { + name: "Organization admin allowed", + userType: "USER", + userID: "0014c2dc-3e89-4369-857d-b420f5786eff", + orgID: "0019f42b-de8f-41ee-904e-3cb6c9ddc8f4", + departmentID: "001a6acb-c174-43f0-9930-0b1242931123", + expectedStatusCode: http.StatusOK, + expectedOrgRole: thunderdome.AdminUserType, + expectedDeptRole: "", + setupMocks: func(mockOrgDataSvc *MockOrganizationDataService) { + mockOrgDataSvc.On( + "DepartmentUserRole", + mock.Anything, + "0014c2dc-3e89-4369-857d-b420f5786eff", + "0019f42b-de8f-41ee-904e-3cb6c9ddc8f4", + "001a6acb-c174-43f0-9930-0b1242931123", + ).Return(thunderdome.AdminUserType, "", nil).Once() + }, + }, + { + name: "Non-admin user forbidden", + userType: "USER", + userID: "3f3d4ca5-6eae-4372-81ba-de8bbaa2dac2", + orgID: "2d6176c8-50d6-4963-8172-2c20ca5022a3", + departmentID: "002738c2-fcf2-438e-a755-2bf9c4233b74", + expectedStatusCode: http.StatusForbidden, + setupMocks: func(mockOrgDataSvc *MockOrganizationDataService) { + mockOrgDataSvc.On( + "DepartmentUserRole", + mock.Anything, + "3f3d4ca5-6eae-4372-81ba-de8bbaa2dac2", + "2d6176c8-50d6-4963-8172-2c20ca5022a3", + "002738c2-fcf2-438e-a755-2bf9c4233b74", + ).Return("MEMBER", "MEMBER", nil).Once() + }, + }, + { + name: "Invalid orgID", + userType: "USER", + userID: "ea840339-2e16-4c10-8744-33ee1b636596", + orgID: "invalid-org-id", + departmentID: "6d72a7dd-4183-4772-8e58-bda948416974", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "Invalid departmentID", + userType: "USER", + userID: "ea840339-2e16-4c10-8744-33ee1b636596", + orgID: "31c8521e-2e68-4898-b3cf-e919cf80dbe2", + departmentID: "invalid-dept-id", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "User not found in department", + userType: "USER", + userID: "ea840339-2e16-4c10-8744-33ee1b636596", + orgID: "128ee064-62ca-43b2-9fca-9c1089c89bd2", + departmentID: "31c8521e-2e68-4898-b3cf-e919cf80dbe2", + expectedStatusCode: http.StatusForbidden, + setupMocks: func(mockOrgDataSvc *MockOrganizationDataService) { + mockOrgDataSvc.On( + "DepartmentUserRole", + mock.Anything, + "ea840339-2e16-4c10-8744-33ee1b636596", + "128ee064-62ca-43b2-9fca-9c1089c89bd2", + "31c8521e-2e68-4898-b3cf-e919cf80dbe2", + ).Return("", "", fmt.Errorf("User not found")).Once() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockOrgDataSvc := new(MockOrganizationDataService) + service := &Service{ + OrganizationDataSvc: mockOrgDataSvc, + } + + if tt.setupMocks != nil { + tt.setupMocks(mockOrgDataSvc) + } + + var capturedOrgRole, capturedDeptRole string + handler := service.departmentAdminOnly(func(w http.ResponseWriter, r *http.Request) { + capturedOrgRole = r.Context().Value(contextKeyOrgRole).(string) + capturedDeptRole = r.Context().Value(contextKeyDepartmentRole).(string) + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest("GET", "/organizations/"+tt.orgID+"/departments/"+tt.departmentID+"/test", nil) + req = mux.SetURLVars(req, map[string]string{"orgId": tt.orgID, "departmentId": tt.departmentID}) + req = req.WithContext(context.WithValue(req.Context(), contextKeyUserID, tt.userID)) + req = req.WithContext(context.WithValue(req.Context(), contextKeyUserType, tt.userType)) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code) + + if tt.expectedStatusCode == http.StatusOK { + assert.Equal(t, tt.expectedOrgRole, capturedOrgRole) + assert.Equal(t, tt.expectedDeptRole, capturedDeptRole) + } + + mockOrgDataSvc.AssertExpectations(t) + }) + } +} + +func TestDepartmentUserOnly(t *testing.T) { + tests := []struct { + name string + userType string + userID string + orgID string + departmentID string + expectedStatusCode int + expectedOrgRole string + expectedDeptRole string + setupMocks func(*MockOrganizationDataService, *MockLogger) + }{ + { + name: "Admin user bypasses role check", + userType: thunderdome.AdminUserType, + userID: "00320d41-1a7c-4e3f-b202-b74ab6ea582c", + orgID: "128ee064-62ca-43b2-9fca-9c1089c89bd2", + departmentID: "31c8521e-2e68-4898-b3cf-e919cf80dbe2", + expectedStatusCode: http.StatusOK, + expectedOrgRole: thunderdome.AdminUserType, + expectedDeptRole: thunderdome.AdminUserType, + }, + { + name: "Department user allowed", + userType: "USER", + userID: "ea840339-2e16-4c10-8744-33ee1b636596", + orgID: "128ee064-62ca-43b2-9fca-9c1089c89bd2", + departmentID: "31c8521e-2e68-4898-b3cf-e919cf80dbe2", + expectedStatusCode: http.StatusOK, + expectedOrgRole: "MEMBER", + expectedDeptRole: "MEMBER", + setupMocks: func(mockOrgDataSvc *MockOrganizationDataService, mockLogger *MockLogger) { + mockOrgDataSvc.On( + "DepartmentUserRole", + mock.Anything, + "ea840339-2e16-4c10-8744-33ee1b636596", + "128ee064-62ca-43b2-9fca-9c1089c89bd2", + "31c8521e-2e68-4898-b3cf-e919cf80dbe2", + ).Return("MEMBER", "MEMBER", nil).Once() + }, + }, + { + name: "Department admin allowed", + userType: "USER", + userID: "3f3d4ca5-6eae-4372-81ba-de8bbaa2dac2", + orgID: "2d6176c8-50d6-4963-8172-2c20ca5022a3", + departmentID: "002738c2-fcf2-438e-a755-2bf9c4233b74", + expectedStatusCode: http.StatusOK, + expectedOrgRole: "MEMBER", + expectedDeptRole: thunderdome.AdminUserType, + setupMocks: func(mockOrgDataSvc *MockOrganizationDataService, mockLogger *MockLogger) { + mockOrgDataSvc.On( + "DepartmentUserRole", + mock.Anything, + "3f3d4ca5-6eae-4372-81ba-de8bbaa2dac2", + "2d6176c8-50d6-4963-8172-2c20ca5022a3", + "002738c2-fcf2-438e-a755-2bf9c4233b74", + ).Return("MEMBER", thunderdome.AdminUserType, nil).Once() + }, + }, + { + name: "Org Member Non-department user forbidden", + userType: "USER", + userID: "0014c2dc-3e89-4369-857d-b420f5786eff", + orgID: "0019f42b-de8f-41ee-904e-3cb6c9ddc8f4", + departmentID: "001a6acb-c174-43f0-9930-0b1242931123", + expectedStatusCode: http.StatusForbidden, + setupMocks: func(mockOrgDataSvc *MockOrganizationDataService, mockLogger *MockLogger) { + mockOrgDataSvc.On( + "DepartmentUserRole", + mock.Anything, + "0014c2dc-3e89-4369-857d-b420f5786eff", + "0019f42b-de8f-41ee-904e-3cb6c9ddc8f4", + "001a6acb-c174-43f0-9930-0b1242931123", + ).Return("MEMBER", "", nil).Once() + }, + }, + { + name: "Non-org Non-department user forbidden", + userType: "USER", + userID: "0014c2dc-3e89-4369-857d-b420f5786efg", + orgID: "0019f42b-de8f-41ee-904e-3cb6c9ddc8f5", + departmentID: "001a6acb-c174-43f0-9930-0b1242931124", + expectedStatusCode: http.StatusForbidden, + setupMocks: func(mockOrgDataSvc *MockOrganizationDataService, mockLogger *MockLogger) { + mockOrgDataSvc.On( + "DepartmentUserRole", + mock.Anything, + "0014c2dc-3e89-4369-857d-b420f5786efg", + "0019f42b-de8f-41ee-904e-3cb6c9ddc8f5", + "001a6acb-c174-43f0-9930-0b1242931124", + ).Return("", "", fmt.Errorf("error getting department users role")).Once() + }, + }, + { + name: "Invalid orgID", + userType: "USER", + userID: "003a65d1-1f9c-4428-a27f-7a7156481412", + orgID: "invalid-org-id", + departmentID: "003ac614-10ff-472e-93cb-0fea025bccb8", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "Invalid departmentID", + userType: "USER", + userID: "003a65d1-1f9c-4428-a27f-7a7156481412", + orgID: "0019f42b-de8f-41ee-904e-3cb6c9ddc8f5", + departmentID: "invalid-dept-id", + expectedStatusCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockOrgDataSvc := new(MockOrganizationDataService) + mockLogger := new(MockLogger) + + mockLogger.On("Ctx", mock.Anything).Return(zap.NewNop()) + + service := &Service{ + OrganizationDataSvc: mockOrgDataSvc, + Logger: otelzap.New(mockLogger.Ctx(context.Background())), + } + + if tt.setupMocks != nil { + tt.setupMocks(mockOrgDataSvc, mockLogger) + } + + var capturedOrgRole, capturedDeptRole string + handler := service.departmentUserOnly(func(w http.ResponseWriter, r *http.Request) { + capturedOrgRole = r.Context().Value(contextKeyOrgRole).(string) + capturedDeptRole = r.Context().Value(contextKeyDepartmentRole).(string) + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest("GET", "/organizations/"+tt.orgID+"/departments/"+tt.departmentID+"/test", nil) + req = mux.SetURLVars(req, map[string]string{"orgId": tt.orgID, "departmentId": tt.departmentID}) + req = req.WithContext(context.WithValue(req.Context(), contextKeyUserID, tt.userID)) + req = req.WithContext(context.WithValue(req.Context(), contextKeyUserType, tt.userType)) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code) + + if tt.expectedStatusCode == http.StatusOK { + assert.Equal(t, tt.expectedOrgRole, capturedOrgRole) + assert.Equal(t, tt.expectedDeptRole, capturedDeptRole) + } + + mockOrgDataSvc.AssertExpectations(t) + }) + } +} diff --git a/internal/http/util.go b/internal/http/util.go index 9761dde9..b8edb5ec 100644 --- a/internal/http/util.go +++ b/internal/http/util.go @@ -124,13 +124,14 @@ func getLimitOffsetFromRequest(r *http.Request) (limit int, offset int) { defaultLimit := 20 defaultOffset := 0 query := r.URL.Query() + Limit, limitErr := strconv.Atoi(query.Get("limit")) - if limitErr != nil || Limit == 0 { + if limitErr != nil || Limit <= 0 { Limit = defaultLimit } Offset, offsetErr := strconv.Atoi(query.Get("offset")) - if offsetErr != nil { + if offsetErr != nil || Offset < 0 { Offset = defaultOffset } diff --git a/internal/http/util_test.go b/internal/http/util_test.go index a753b06b..38a6420e 100644 --- a/internal/http/util_test.go +++ b/internal/http/util_test.go @@ -1,6 +1,9 @@ package http import ( + "net/http" + "net/url" + "strings" "testing" "github.com/go-playground/validator/v10" @@ -11,42 +14,320 @@ func TestMain(m *testing.M) { m.Run() } -// TestValidUserAccount calls validateUserAccountWithPasswords with valid user inputs for name, email, password1, and password2 -func TestValidUserAccount(t *testing.T) { - Name := "Thor" - Email := "thor@thunderdome.dev" - Password := "lokiIsAJoke" +func TestValidateUserAccountWithPasswords(t *testing.T) { + tests := []struct { + name string + inputName string + inputEmail string + inputPassword1 string + inputPassword2 string + wantErr bool + }{ + { + name: "Valid user account", + inputName: "Thor", + inputEmail: "thor@thunderdome.dev", + inputPassword1: "lokiIsAJoke", + inputPassword2: "lokiIsAJoke", + wantErr: false, + }, + { + name: "Invalid email", + inputName: "Thor", + inputEmail: "thor", + inputPassword1: "lokiIsAJoke", + inputPassword2: "lokiIsAJoke", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, email, password, err := validateUserAccountWithPasswords(tt.inputName, tt.inputEmail, tt.inputPassword1, tt.inputPassword2) - name, email, password, err := validateUserAccountWithPasswords(Name, Email, Password, Password) - if err != nil || (Name != name || Email != email || Password != password) { - t.Fatalf(`validateUserAccountWithPasswords = %v error`, err) + if (err != nil) != tt.wantErr { + t.Errorf("validateUserAccountWithPasswords() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if name != tt.inputName || email != tt.inputEmail || password != tt.inputPassword1 { + t.Errorf("validateUserAccountWithPasswords() = (%v, %v, %v), want (%v, %v, %v)", + name, email, password, tt.inputName, tt.inputEmail, tt.inputPassword1) + } + } + }) } } -// TestInvalidUserAccount calls validateUserAccountWithPasswords with invalid user input for email -func TestInvalidUserAccount(t *testing.T) { - _, _, _, err := validateUserAccountWithPasswords("Thor", "thor", "lokiIsAJoke", "lokiIsAJoke") - if err == nil { - t.Fatalf(`validateUserAccountWithPasswords = %v, want error`, err) +func TestValidateUserPassword(t *testing.T) { + tests := []struct { + name string + password1 string + password2 string + wantErr bool + }{ + { + name: "Matching passwords", + password1: "lokiIsAJoke", + password2: "lokiIsAJoke", + wantErr: false, + }, + { + name: "Non-matching passwords", + password1: "lokiIsAJoke", + password2: "lokiIsAJokeFail", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + password, err := validateUserPassword(tt.password1, tt.password2) + + if (err != nil) != tt.wantErr { + t.Errorf("validateUserPassword() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && password != tt.password1 { + t.Errorf("validateUserPassword() = %v, want %v", password, tt.password1) + } + }) } } -// TestValidUserPassword calls validateUserPassword with valid user password1 and password2 -func TestValidUserPassword(t *testing.T) { - Password := "lokiIsAJoke" +func TestGetLimitOffsetFromRequest(t *testing.T) { + tests := []struct { + name string + queryParams url.Values + expectedLimit int + expectedOffset int + }{ + { + name: "Default values", + queryParams: url.Values{}, + expectedLimit: 20, + expectedOffset: 0, + }, + { + name: "Valid limit and offset", + queryParams: url.Values{"limit": []string{"30"}, "offset": []string{"10"}}, + expectedLimit: 30, + expectedOffset: 10, + }, + { + name: "Invalid limit (use default)", + queryParams: url.Values{"limit": []string{"invalid"}, "offset": []string{"5"}}, + expectedLimit: 20, + expectedOffset: 5, + }, + { + name: "Invalid offset (use default)", + queryParams: url.Values{"limit": []string{"25"}, "offset": []string{"invalid"}}, + expectedLimit: 25, + expectedOffset: 0, + }, + { + name: "Zero limit (use default)", + queryParams: url.Values{"limit": []string{"0"}, "offset": []string{"15"}}, + expectedLimit: 20, + expectedOffset: 15, + }, + { + name: "Negative values (use defaults)", + queryParams: url.Values{"limit": []string{"-10"}, "offset": []string{"-5"}}, + expectedLimit: 20, + expectedOffset: 0, + }, + { + name: "Negative limit, valid offset", + queryParams: url.Values{"limit": []string{"-10"}, "offset": []string{"5"}}, + expectedLimit: 20, + expectedOffset: 5, + }, + { + name: "Valid limit, negative offset", + queryParams: url.Values{"limit": []string{"30"}, "offset": []string{"-5"}}, + expectedLimit: 30, + expectedOffset: 0, + }, + { + name: "Very large values", + queryParams: url.Values{"limit": []string{"1000000"}, "offset": []string{"9999999"}}, + expectedLimit: 1000000, + expectedOffset: 9999999, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("GET", "/?"+tt.queryParams.Encode(), nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } - password, err := validateUserPassword(Password, Password) - if err != nil || (Password != password) { - t.Fatalf(`validateUserAccountWithPasswords = %v error`, err) + limit, offset := getLimitOffsetFromRequest(req) + + if limit != tt.expectedLimit { + t.Errorf("Expected limit %d, but got %d", tt.expectedLimit, limit) + } + if offset != tt.expectedOffset { + t.Errorf("Expected offset %d, but got %d", tt.expectedOffset, offset) + } + }) } } -// TestInvalidUserPassword calls validateUserPassword with invalid user password1 or password2 -func TestInvalidUserPassword(t *testing.T) { - Password := "lokiIsAJoke" +func TestGetSearchFromRequest(t *testing.T) { + tests := []struct { + name string + queryParams url.Values + expectedSearch string + expectError bool + }{ + { + name: "Valid search query", + queryParams: url.Values{"search": []string{"validquery"}}, + expectedSearch: "validquery", + expectError: false, + }, + { + name: "Minimum length search query", + queryParams: url.Values{"search": []string{"abc"}}, + expectedSearch: "abc", + expectError: false, + }, + { + name: "Empty search query", + queryParams: url.Values{"search": []string{""}}, + expectedSearch: "", + expectError: true, + }, + { + name: "Missing search query", + queryParams: url.Values{}, + expectedSearch: "", + expectError: true, + }, + { + name: "Too short search query", + queryParams: url.Values{"search": []string{"ab"}}, + expectedSearch: "", + expectError: true, + }, + { + name: "Long search query", + queryParams: url.Values{"search": []string{"thisisaverylongsearchquery"}}, + expectedSearch: "thisisaverylongsearchquery", + expectError: false, + }, + { + name: "Search query with spaces", + queryParams: url.Values{"search": []string{"search with spaces"}}, + expectedSearch: "search with spaces", + expectError: false, + }, + { + name: "Search query with special characters", + queryParams: url.Values{"search": []string{"search@#$%^&*"}}, + expectedSearch: "search@#$%^&*", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("GET", "/?"+tt.queryParams.Encode(), nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + search, err := getSearchFromRequest(req) + + if tt.expectError { + if err == nil { + t.Errorf("Expected an error, but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if search != tt.expectedSearch { + t.Errorf("Expected search '%s', but got '%s'", tt.expectedSearch, search) + } + } + }) + } +} + +func TestSanitizeUserInputForLogs(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "No newlines", + input: "This is a normal string", + expected: "This is a normal string", + }, + { + name: "With Unix newlines", + input: "This has\na newline", + expected: "This hasa newline", + }, + { + name: "With Windows newlines", + input: "This has\r\na Windows newline", + expected: "This hasa Windows newline", + }, + { + name: "With mixed newlines", + input: "This has\nmixed\r\nnewlines", + expected: "This hasmixednewlines", + }, + { + name: "Only newlines", + input: "\n\r\n\n\r\n", + expected: "", + }, + { + name: "Empty string", + input: "", + expected: "", + }, + { + name: "String with only spaces", + input: " ", + expected: " ", + }, + { + name: "Newlines at start and end", + input: "\n\r\nThis is a test\n\r\n", + expected: "This is a test", + }, + { + name: "With tabs", + input: "This\thas\ttabs\nand newlines", + expected: "This\thas\ttabsand newlines", + }, + { + name: "With other whitespace characters", + input: "This has\u000B\u000C", + expected: "This has\u000B\u000C", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeUserInputForLogs(tt.input) + if result != tt.expected { + t.Errorf("Expected '%s', but got '%s'", tt.expected, result) + } - _, err := validateUserPassword(Password, Password+"fail") - if err == nil { - t.Fatalf(`validateUserAccountWithPasswords = %v, want error`, err) + // Additional check to ensure no newlines remain + if strings.Contains(result, "\n") || strings.Contains(result, "\r") { + t.Errorf("Sanitized string still contains newline characters: %q", result) + } + }) } }