diff --git a/.secrets.baseline b/.secrets.baseline index 88c34c81..7aa82ccd 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "go.mod|go.sum|^.secrets.baseline$", "lines": null }, - "generated_at": "2025-11-27T10:58:34Z", + "generated_at": "2026-04-21T09:42:22Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -340,7 +340,7 @@ "hashed_secret": "6947818ac409551f11fbaa78f0ea6391960aa5b8", "is_secret": false, "is_verified": false, - "line_number": 115, + "line_number": 121, "type": "Secret Keyword", "verified_result": null } diff --git a/api/internal/pkg/pac-go-server/db/interface.go b/api/internal/pkg/pac-go-server/db/interface.go index 2d0bca85..6142e643 100644 --- a/api/internal/pkg/pac-go-server/db/interface.go +++ b/api/internal/pkg/pac-go-server/db/interface.go @@ -2,6 +2,7 @@ package db import ( "context" + "time" "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/models" ) @@ -47,4 +48,11 @@ type DB interface { InsertFeedback(*models.Feedback) error GetFeedbacks(models.FeedbacksFilter, int64, int64) ([]models.Feedback, int64, error) FeedbackAllowed(context.Context, string) (bool, error) + + // Maintenance window operations + GetAllMaintenanceWindows() ([]models.MaintenanceWindow, error) + GetMaintenanceWindowByID(id string) (*models.MaintenanceWindow, error) + CreateMaintenanceWindow(window *models.MaintenanceWindow) error + UpdateMaintenanceWindow(window *models.MaintenanceWindow) error + DeleteMaintenanceWindow(id string, deletedBy string, deletedAt *time.Time) error } diff --git a/api/internal/pkg/pac-go-server/db/mock_db_client.go b/api/internal/pkg/pac-go-server/db/mock_db_client.go index 4a750a6d..20b35eea 100644 --- a/api/internal/pkg/pac-go-server/db/mock_db_client.go +++ b/api/internal/pkg/pac-go-server/db/mock_db_client.go @@ -7,6 +7,7 @@ package db import ( "context" reflect "reflect" + "time" models "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/models" gomock "github.com/golang/mock/gomock" @@ -374,6 +375,78 @@ func (mr *MockDBMockRecorder) FeedbackAllowed(arg0, arg1 interface{}) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FeedbackAllowed", reflect.TypeOf((*MockDB)(nil).FeedbackAllowed), arg0, arg1) } +// GetAllMaintenanceWindows mocks base method. +func (m *MockDB) GetAllMaintenanceWindows() ([]models.MaintenanceWindow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllMaintenanceWindows") + ret0, _ := ret[0].([]models.MaintenanceWindow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllMaintenanceWindows indicates an expected call of GetAllMaintenanceWindows. +func (mr *MockDBMockRecorder) GetAllMaintenanceWindows() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllMaintenanceWindows", reflect.TypeOf((*MockDB)(nil).GetAllMaintenanceWindows)) +} + +// GetMaintenanceWindowByID mocks base method. +func (m *MockDB) GetMaintenanceWindowByID(arg0 string) (*models.MaintenanceWindow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMaintenanceWindowByID", arg0) + ret0, _ := ret[0].(*models.MaintenanceWindow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetMaintenanceWindowByID indicates an expected call of GetMaintenanceWindowByID. +func (mr *MockDBMockRecorder) GetMaintenanceWindowByID(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaintenanceWindowByID", reflect.TypeOf((*MockDB)(nil).GetMaintenanceWindowByID), arg0) +} + +// CreateMaintenanceWindow mocks base method. +func (m *MockDB) CreateMaintenanceWindow(arg0 *models.MaintenanceWindow) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateMaintenanceWindow", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateMaintenanceWindow indicates an expected call of CreateMaintenanceWindow. +func (mr *MockDBMockRecorder) CreateMaintenanceWindow(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMaintenanceWindow", reflect.TypeOf((*MockDB)(nil).CreateMaintenanceWindow), arg0) +} + +// UpdateMaintenanceWindow mocks base method. +func (m *MockDB) UpdateMaintenanceWindow(arg0 *models.MaintenanceWindow) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateMaintenanceWindow", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateMaintenanceWindow indicates an expected call of UpdateMaintenanceWindow. +func (mr *MockDBMockRecorder) UpdateMaintenanceWindow(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMaintenanceWindow", reflect.TypeOf((*MockDB)(nil).UpdateMaintenanceWindow), arg0) +} + +// DeleteMaintenanceWindow mocks base method. +func (m *MockDB) DeleteMaintenanceWindow(arg0 string, arg1 string, arg2 *time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteMaintenanceWindow", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteMaintenanceWindow indicates an expected call of DeleteMaintenanceWindow. +func (mr *MockDBMockRecorder) DeleteMaintenanceWindow(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMaintenanceWindow", reflect.TypeOf((*MockDB)(nil).DeleteMaintenanceWindow), arg0, arg1, arg2) +} + // MarkEventAsNotified mocks base method. func (m *MockDB) MarkEventAsNotified(arg0 string) error { m.ctrl.T.Helper() diff --git a/api/internal/pkg/pac-go-server/db/mongodb/maintenance.go b/api/internal/pkg/pac-go-server/db/mongodb/maintenance.go new file mode 100644 index 00000000..26dbfb28 --- /dev/null +++ b/api/internal/pkg/pac-go-server/db/mongodb/maintenance.go @@ -0,0 +1,143 @@ +package mongodb + +import ( + "context" + "fmt" + "time" + + "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/models" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" +) + +// GetAllMaintenanceWindows retrieves all non-deleted maintenance windows from the database +func (db *MongoDB) GetAllMaintenanceWindows() ([]models.MaintenanceWindow, error) { + collection := db.Database.Collection("maintenance_windows") + ctx, cancel := context.WithTimeout(context.Background(), dbContextTimeout) + defer cancel() + + // Filter out soft-deleted records + filter := bson.M{"deleted_at": bson.M{"$exists": false}} + cursor, err := collection.Find(ctx, filter) + if err != nil { + return nil, fmt.Errorf("error fetching maintenance windows from DB: %w", err) + } + defer cursor.Close(ctx) + + var windows []models.MaintenanceWindow + if err := cursor.All(ctx, &windows); err != nil { + return nil, fmt.Errorf("error decoding maintenance windows: %w", err) + } + + return windows, nil +} + +// GetMaintenanceWindowByID retrieves a specific non-deleted maintenance window by ID +func (db *MongoDB) GetMaintenanceWindowByID(id string) (*models.MaintenanceWindow, error) { + collection := db.Database.Collection("maintenance_windows") + ctx, cancel := context.WithTimeout(context.Background(), dbContextTimeout) + defer cancel() + + objectID, err := primitive.ObjectIDFromHex(id) + if err != nil { + return nil, fmt.Errorf("invalid maintenance window ID: %w", err) + } + + var window models.MaintenanceWindow + // Filter out soft-deleted records + filter := bson.M{ + "_id": objectID, + "deleted_at": bson.M{"$exists": false}, + } + + err = collection.FindOne(ctx, filter).Decode(&window) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, fmt.Errorf("error fetching maintenance window from DB: %w", err) + } + + return &window, nil +} + +// CreateMaintenanceWindow creates a new maintenance window +func (db *MongoDB) CreateMaintenanceWindow(window *models.MaintenanceWindow) error { + collection := db.Database.Collection("maintenance_windows") + ctx, cancel := context.WithTimeout(context.Background(), dbContextTimeout) + defer cancel() + + _, err := collection.InsertOne(ctx, window) + if err != nil { + return fmt.Errorf("error creating maintenance window: %w", err) + } + + return nil +} + +// UpdateMaintenanceWindow updates an existing maintenance window +func (db *MongoDB) UpdateMaintenanceWindow(window *models.MaintenanceWindow) error { + collection := db.Database.Collection("maintenance_windows") + ctx, cancel := context.WithTimeout(context.Background(), dbContextTimeout) + defer cancel() + + filter := bson.M{"_id": window.ID} + update := bson.M{ + "$set": bson.M{ + "enabled": window.Enabled, + "start_time": window.StartTime, + "end_time": window.EndTime, + "message": window.Message, + "updated_by": window.UpdatedBy, + "updated_at": window.UpdatedAt, + }, + } + + result, err := collection.UpdateOne(ctx, filter, update) + if err != nil { + return fmt.Errorf("error updating maintenance window: %w", err) + } + + if result.MatchedCount == 0 { + return fmt.Errorf("maintenance window not found") + } + + return nil +} + +// DeleteMaintenanceWindow performs soft delete on a maintenance window by ID +func (db *MongoDB) DeleteMaintenanceWindow(id string, deletedBy string, deletedAt *time.Time) error { + collection := db.Database.Collection("maintenance_windows") + ctx, cancel := context.WithTimeout(context.Background(), dbContextTimeout) + defer cancel() + + objectID, err := primitive.ObjectIDFromHex(id) + if err != nil { + return fmt.Errorf("invalid maintenance window ID: %w", err) + } + + // Only soft delete non-deleted records + filter := bson.M{ + "_id": objectID, + "deleted_at": bson.M{"$exists": false}, + } + + update := bson.M{ + "$set": bson.M{ + "deleted_by": deletedBy, + "deleted_at": deletedAt, + }, + } + + result, err := collection.UpdateOne(ctx, filter, update) + if err != nil { + return fmt.Errorf("error soft deleting maintenance window: %w", err) + } + + if result.MatchedCount == 0 { + return fmt.Errorf("maintenance window not found or already deleted") + } + + return nil +} diff --git a/api/internal/pkg/pac-go-server/models/maintenance.go b/api/internal/pkg/pac-go-server/models/maintenance.go new file mode 100644 index 00000000..a434eaa9 --- /dev/null +++ b/api/internal/pkg/pac-go-server/models/maintenance.go @@ -0,0 +1,77 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// MaintenanceWindow represents a single maintenance notification window +type MaintenanceWindow struct { + ID primitive.ObjectID `bson:"_id" json:"id"` + Enabled bool `bson:"enabled" json:"enabled"` + StartTime time.Time `bson:"start_time" json:"start_time"` + EndTime time.Time `bson:"end_time" json:"end_time"` + Message string `bson:"message" json:"message"` + CreatedBy string `bson:"created_by" json:"created_by"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedBy string `bson:"updated_by" json:"updated_by"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` + DeletedBy string `bson:"deleted_by,omitempty" json:"deleted_by,omitempty"` + DeletedAt *time.Time `bson:"deleted_at,omitempty" json:"deleted_at,omitempty"` +} + +// MaintenanceResponse is the public response for a single maintenance window +type MaintenanceResponse struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Message string `json:"message"` + IsActive bool `json:"is_active"` +} + +// MaintenanceAdminResponse is the admin response with audit fields +type MaintenanceAdminResponse struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Message string `json:"message"` + IsActive bool `json:"is_active"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedBy string `json:"updated_by,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + DeletedBy string `json:"deleted_by,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} + +// MaintenanceListResponse is the public response for listing all maintenance windows +type MaintenanceListResponse struct { + Maintenances []MaintenanceResponse `json:"maintenances"` +} + +// MaintenanceAdminListResponse is the admin response for listing all maintenance windows with pagination +type MaintenanceAdminListResponse struct { + TotalPages int64 `json:"total_pages"` + TotalItems int64 `json:"total_items"` + Maintenances []MaintenanceAdminResponse `json:"maintenances"` + Links Links `json:"links"` +} + +// MaintenanceCreateRequest is the request body for creating a new maintenance window +type MaintenanceCreateRequest struct { + Enabled bool `json:"enabled"` + StartTime time.Time `json:"start_time" binding:"required"` + EndTime time.Time `json:"end_time" binding:"required"` + Message string `json:"message" binding:"required"` +} + +// MaintenanceUpdateRequest is the request body for updating an existing maintenance window +type MaintenanceUpdateRequest struct { + Enabled bool `json:"enabled"` + StartTime time.Time `json:"start_time,omitempty"` + EndTime time.Time `json:"end_time,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/api/internal/pkg/pac-go-server/router/router.go b/api/internal/pkg/pac-go-server/router/router.go index 135a8531..00f73942 100644 --- a/api/internal/pkg/pac-go-server/router/router.go +++ b/api/internal/pkg/pac-go-server/router/router.go @@ -94,6 +94,10 @@ func CreateRouter() *gin.Engine { authorizedAdmin.GET("/users/:id", services.GetUser) authorizedAdmin.GET("/feedbacks", services.GetFeedback) + + authorizedAdmin.POST("/maintenance", services.CreateMaintenanceWindow) + authorizedAdmin.PUT("/maintenance/:id", services.UpdateMaintenanceWindow) + authorizedAdmin.DELETE("/maintenance/:id", services.DeleteMaintenanceWindow) } // user related endpoints @@ -124,5 +128,8 @@ func CreateRouter() *gin.Engine { // feedback related endpoints authorized.POST("/feedbacks", services.CreateFeedback) + // maintenance notification related endpoints + authorized.GET("/maintenance", services.GetMaintenanceWindows) + return router } diff --git a/api/internal/pkg/pac-go-server/services/backend_test.go b/api/internal/pkg/pac-go-server/services/backend_test.go index 624965ed..b90bac10 100644 --- a/api/internal/pkg/pac-go-server/services/backend_test.go +++ b/api/internal/pkg/pac-go-server/services/backend_test.go @@ -15,6 +15,7 @@ import ( "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/utils" "github.com/Nerzal/gocloak/v13" "github.com/golang/mock/gomock" + "go.mongodb.org/mongo-driver/bson/primitive" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -591,6 +592,75 @@ func getResource(apiType string, customValues map[string]interface{}) interface{ CreatedAt: time.Now(), } return []models.Feedback{feedback} + case "get-all-maintenance-windows": + now := time.Now() + objectID1, _ := primitive.ObjectIDFromHex("507f1f77bcf86cd799439011") + objectID2, _ := primitive.ObjectIDFromHex("507f1f77bcf86cd799439012") + window1 := models.MaintenanceWindow{ + ID: objectID1, + Enabled: true, + StartTime: now.Add(24 * time.Hour), + EndTime: now.Add(48 * time.Hour), + Message: "Scheduled maintenance for PowerVS infrastructure upgrade", + CreatedBy: "admin-user-123", + CreatedAt: now, + UpdatedBy: "admin-user-123", + UpdatedAt: now, + } + window2 := models.MaintenanceWindow{ + ID: objectID2, + Enabled: false, + StartTime: now.Add(72 * time.Hour), + EndTime: now.Add(96 * time.Hour), + Message: "Planned network maintenance", + CreatedBy: "admin-user-456", + CreatedAt: now, + UpdatedBy: "admin-user-456", + UpdatedAt: now, + } + return []models.MaintenanceWindow{window1, window2} + case "get-maintenance-window-by-id": + now := time.Now() + objectID, _ := primitive.ObjectIDFromHex("507f1f77bcf86cd799439011") + window := models.MaintenanceWindow{ + ID: objectID, + Enabled: true, + StartTime: now.Add(24 * time.Hour), + EndTime: now.Add(48 * time.Hour), + Message: "Scheduled maintenance for PowerVS infrastructure upgrade", + CreatedBy: "admin-user-123", + CreatedAt: now, + UpdatedBy: "admin-user-123", + UpdatedAt: now, + } + return &window + case "create-maintenance-window-request": + now := time.Now() + request := models.MaintenanceCreateRequest{ + Enabled: true, + StartTime: now.Add(24 * time.Hour), + EndTime: now.Add(48 * time.Hour), + Message: "Scheduled maintenance for PowerVS infrastructure upgrade", + } + return &request + case "create-maintenance-window-invalid-time": + now := time.Now() + request := models.MaintenanceCreateRequest{ + Enabled: true, + StartTime: now.Add(48 * time.Hour), + EndTime: now.Add(24 * time.Hour), // End time before start time + Message: "Invalid time range", + } + return &request + case "update-maintenance-window-request": + now := time.Now() + request := models.MaintenanceUpdateRequest{ + Enabled: false, + StartTime: now.Add(36 * time.Hour), + EndTime: now.Add(60 * time.Hour), + Message: "Updated maintenance message", + } + return &request default: return nil } diff --git a/api/internal/pkg/pac-go-server/services/maintenance.go b/api/internal/pkg/pac-go-server/services/maintenance.go new file mode 100644 index 00000000..94790c94 --- /dev/null +++ b/api/internal/pkg/pac-go-server/services/maintenance.go @@ -0,0 +1,469 @@ +package services + +import ( + "fmt" + "net/http" + "time" + + "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/client" + log "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/logger" + "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/models" + "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/utils" + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +const ( + earlyWarningHours = 24 * time.Hour + timeDisplayFormat = "Jan 02, 2006 03:04 PM" +) + +// GetMaintenanceWindows godoc +// @Summary Get maintenance notification windows +// @Description Get maintenance windows. Use ?all=true (admin only) to get all windows with audit details and pagination, otherwise returns only the earliest active window +// @Tags maintenance +// @Accept json +// @Produce json +// @Param all query bool false "Get all windows (admin only)" +// @Param page query int false "Page number for pagination (admin only)" +// @Param per_page query int false "Number of items per page (admin only)" +// @Success 200 {object} models.MaintenanceListResponse +// @Router /api/v1/maintenance [get] +func GetMaintenanceWindows(c *gin.Context) { + logger := log.GetLogger() + + // Check if admin wants all windows + showAll := c.Query("all") == "true" + + windows, err := dbCon.GetAllMaintenanceWindows() + if err != nil { + logger.Error("failed to get maintenance windows from db", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve maintenance windows"}) + return + } + + now := time.Now() + + // Admin view: return all windows with audit details and pagination + if showAll { + adminResponses := buildAdminResponses(c, windows, now) + if adminResponses == nil { + return // Error already handled in buildAdminResponses + } + + // Apply pagination + pageInt, perPageInt := utils.GetCurrentPageAndPageCount(c) + startIndex := int((pageInt - 1) * perPageInt) + endIndex := int(pageInt * perPageInt) + + totalCount := int64(len(adminResponses)) + + // Handle pagination bounds + if startIndex >= len(adminResponses) { + startIndex = len(adminResponses) + } + if endIndex > len(adminResponses) { + endIndex = len(adminResponses) + } + + paginatedResponses := adminResponses[startIndex:endIndex] + totalPages := utils.GetTotalPages(totalCount, perPageInt) + + c.JSON(http.StatusOK, models.MaintenanceAdminListResponse{ + TotalPages: totalPages, + TotalItems: totalCount, + Maintenances: paginatedResponses, + Links: models.Links{ + Self: c.Request.URL.String(), + Next: getNextPageLink(c, pageInt, totalPages), + Last: getLastPageLink(c, totalPages), + }, + }) + return + } + + // User view: return only the earliest active window (no pagination needed) + responses := buildUserResponses(windows, now) + c.JSON(http.StatusOK, models.MaintenanceListResponse{ + Maintenances: responses, + }) +} + +// buildAdminResponses creates admin response with all windows and audit details +func buildAdminResponses(c *gin.Context, windows []models.MaintenanceWindow, now time.Time) []models.MaintenanceAdminResponse { + // Verify admin access + config := client.GetConfigFromContext(c.Request.Context()) + kc := client.NewKeyCloakClient(config, c.Request.Context()) + + if !kc.IsRole(utils.ManagerRole) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return nil + } + + adminResponses := make([]models.MaintenanceAdminResponse, 0, len(windows)) + for _, window := range windows { + adminResponses = append(adminResponses, models.MaintenanceAdminResponse{ + ID: window.ID.Hex(), + Enabled: window.Enabled, + StartTime: window.StartTime, + EndTime: window.EndTime, + Message: window.Message, + CreatedBy: window.CreatedBy, + CreatedAt: window.CreatedAt, + UpdatedBy: window.UpdatedBy, + UpdatedAt: window.UpdatedAt, + DeletedBy: window.DeletedBy, + DeletedAt: window.DeletedAt, + IsActive: isWindowActive(window, now), + }) + } + + return adminResponses +} + +// buildUserResponses creates user response with only the earliest active window +func buildUserResponses(windows []models.MaintenanceWindow, now time.Time) []models.MaintenanceResponse { + var earliestActiveWindow *models.MaintenanceWindow + + for i := range windows { + window := &windows[i] + + // Skip if not enabled or not active + if !window.Enabled || !isWindowActive(*window, now) { + continue + } + + // Keep only the earliest active window (by start time) + if earliestActiveWindow == nil || window.StartTime.Before(earliestActiveWindow.StartTime) { + earliestActiveWindow = window + } + } + + // Return only the earliest active window (or empty array if none) + if earliestActiveWindow == nil { + return []models.MaintenanceResponse{} + } + + return []models.MaintenanceResponse{{ + ID: earliestActiveWindow.ID.Hex(), + Enabled: earliestActiveWindow.Enabled, + StartTime: earliestActiveWindow.StartTime, + EndTime: earliestActiveWindow.EndTime, + Message: earliestActiveWindow.Message, + IsActive: true, + }} +} + +// isWindowActive checks if a maintenance window is currently active +func isWindowActive(window models.MaintenanceWindow, now time.Time) bool { + earlyWarningTime := window.StartTime.Add(-earlyWarningHours) + return window.Enabled && now.After(earlyWarningTime) && now.Before(window.EndTime) +} + +// checkForOverlaps checks if the given time range overlaps with any existing enabled windows +// Returns error if overlap is found +func checkForOverlaps(startTime, endTime time.Time, excludeID string) error { + windows, err := dbCon.GetAllMaintenanceWindows() + if err != nil { + return err + } + + for _, window := range windows { + // Skip the window being updated or disabled windows + if (excludeID != "" && window.ID.Hex() == excludeID) || !window.Enabled { + continue + } + + // Check for overlap: startTime < window.EndTime AND endTime > window.StartTime + if startTime.Before(window.EndTime) && endTime.After(window.StartTime) { + return formatOverlapError(window.StartTime, window.EndTime) + } + } + + return nil +} + +// formatOverlapError creates a user-friendly overlap error message +func formatOverlapError(existingStart, existingEnd time.Time) error { + localStart := existingStart.In(time.Local).Format(timeDisplayFormat) + localEnd := existingEnd.In(time.Local).Format(timeDisplayFormat) + + return fmt.Errorf("Start time overlaps with existing maintenance window (%s to %s). Please update the existing window's end time instead of creating a new one", + localStart, localEnd) +} + +// CreateMaintenanceWindow godoc +// @Summary Create a new maintenance notification window +// @Description Create a new maintenance notification window (admin only) +// @Tags maintenance +// @Accept json +// @Produce json +// @Param maintenance body models.MaintenanceCreateRequest true "Maintenance window configuration" +// @Param Authorization header string true "Insert your access token" default(Bearer ) +// @Success 201 {object} models.MaintenanceResponse +// @Router /api/v1/maintenance [post] +func CreateMaintenanceWindow(c *gin.Context) { + logger := log.GetLogger() + + var request models.MaintenanceCreateRequest + if err := utils.BindAndValidate(c, &request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + // Get user ID from Keycloak client + userID := getUserID(c) + now := time.Now() + + // Validate request + if err := validateTimeRange(request.StartTime, request.EndTime, now, true); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Check for overlapping windows + if err := checkForOverlaps(request.StartTime, request.EndTime, ""); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Create maintenance window + window := &models.MaintenanceWindow{ + ID: primitive.NewObjectID(), + Enabled: request.Enabled, + StartTime: request.StartTime, + EndTime: request.EndTime, + Message: request.Message, + CreatedBy: userID, + CreatedAt: now, + UpdatedBy: userID, + UpdatedAt: now, + } + + if err := dbCon.CreateMaintenanceWindow(window); err != nil { + logger.Error("failed to create maintenance window in db", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create maintenance window"}) + return + } + + logger.Info("maintenance window created", + zap.String("id", window.ID.Hex()), + zap.String("created_by", userID), + zap.Bool("enabled", window.Enabled)) + + logMaintenanceEvent(logger, userID, window.ID.Hex(), "maintenance_window_create", window.Enabled) + + c.JSON(http.StatusCreated, buildMaintenanceResponse(window)) +} + +// UpdateMaintenanceWindow godoc +// @Summary Update a maintenance notification window +// @Description Update an existing maintenance notification window (admin only) +// @Tags maintenance +// @Accept json +// @Produce json +// @Param id path string true "Maintenance Window ID" +// @Param maintenance body models.MaintenanceUpdateRequest true "Maintenance window configuration" +// @Param Authorization header string true "Insert your access token" default(Bearer ) +// @Success 200 {object} models.MaintenanceResponse +// @Router /api/v1/maintenance/{id} [put] +func UpdateMaintenanceWindow(c *gin.Context) { + logger := log.GetLogger() + id := c.Param("id") + + var request models.MaintenanceUpdateRequest + if err := utils.BindAndValidate(c, &request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + userID := getUserID(c) + now := time.Now() + + // Get existing window + existingWindow, err := dbCon.GetMaintenanceWindowByID(id) + if err != nil { + logger.Error("failed to get maintenance window from db", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve maintenance window"}) + return + } + + if existingWindow == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Maintenance window not found"}) + return + } + + // Apply updates and validate + if err := applyWindowUpdates(existingWindow, &request, userID, now, id); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := dbCon.UpdateMaintenanceWindow(existingWindow); err != nil { + logger.Error("failed to update maintenance window in db", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update maintenance window"}) + return + } + + logger.Info("maintenance window updated", + zap.String("id", existingWindow.ID.Hex()), + zap.String("updated_by", userID), + zap.Bool("enabled", existingWindow.Enabled)) + + logMaintenanceEvent(logger, userID, existingWindow.ID.Hex(), "maintenance_window_update", existingWindow.Enabled) + + c.JSON(http.StatusOK, buildMaintenanceResponse(existingWindow)) +} + +// applyWindowUpdates applies the update request to the existing window and validates +func applyWindowUpdates(window *models.MaintenanceWindow, request *models.MaintenanceUpdateRequest, userID string, now time.Time, windowID string) error { + // Determine final times + finalStartTime := window.StartTime + finalEndTime := window.EndTime + + if !request.StartTime.IsZero() { + finalStartTime = request.StartTime + } + if !request.EndTime.IsZero() { + finalEndTime = request.EndTime + } + + // Validate new start time if changed + if !request.StartTime.IsZero() && !request.StartTime.Equal(window.StartTime) { + if request.StartTime.Before(now) { + return fmt.Errorf("start_time cannot be in the past") + } + } + + // Validate time range + if err := validateTimeRange(finalStartTime, finalEndTime, now, false); err != nil { + return err + } + + // Check for overlaps + if err := checkForOverlaps(finalStartTime, finalEndTime, windowID); err != nil { + return err + } + + // Apply updates + window.Enabled = request.Enabled + if !request.StartTime.IsZero() { + window.StartTime = request.StartTime + } + if !request.EndTime.IsZero() { + window.EndTime = request.EndTime + } + if request.Message != "" { + window.Message = request.Message + } + window.UpdatedBy = userID + window.UpdatedAt = now + + return nil +} + +// DeleteMaintenanceWindow godoc +// @Summary Delete a maintenance notification window +// @Description Delete a maintenance notification window (admin only) +// @Tags maintenance +// @Accept json +// @Produce json +// @Param id path string true "Maintenance Window ID" +// @Param Authorization header string true "Insert your access token" default(Bearer ) +// @Success 204 "No Content" +// @Router /api/v1/maintenance/{id} [delete] +func DeleteMaintenanceWindow(c *gin.Context) { + logger := log.GetLogger() + id := c.Param("id") + userID := getUserID(c) + + // Check if window exists + window, err := dbCon.GetMaintenanceWindowByID(id) + if err != nil { + logger.Error("failed to get maintenance window from db", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve maintenance window"}) + return + } + + if window == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Maintenance window not found"}) + return + } + + // Perform soft delete + now := time.Now() + if err := dbCon.DeleteMaintenanceWindow(id, userID, &now); err != nil { + logger.Error("failed to soft delete maintenance window from db", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete maintenance window"}) + return + } + + logger.Info("maintenance window soft deleted", + zap.String("id", id), + zap.String("deleted_by", userID)) + + logMaintenanceEvent(logger, userID, id, "maintenance_window_delete", false) + + c.Status(http.StatusNoContent) +} + +// Helper functions + +// getUserID extracts user ID from Keycloak context +func getUserID(c *gin.Context) string { + config := client.GetConfigFromContext(c.Request.Context()) + kc := client.NewKeyCloakClient(config, c.Request.Context()) + return kc.GetUserID() +} + +// validateTimeRange validates that end time is after start time and optionally checks if start is in future +func validateTimeRange(startTime, endTime, now time.Time, checkFuture bool) error { + if checkFuture && startTime.Before(now) { + return fmt.Errorf("start_time cannot be in the past") + } + + if endTime.Before(startTime) || endTime.Equal(startTime) { + return fmt.Errorf("end_time must be after start_time") + } + + return nil +} + +// buildMaintenanceResponse creates a public maintenance response +func buildMaintenanceResponse(window *models.MaintenanceWindow) models.MaintenanceResponse { + return models.MaintenanceResponse{ + ID: window.ID.Hex(), + Enabled: window.Enabled, + StartTime: window.StartTime, + EndTime: window.EndTime, + Message: window.Message, + } +} + +// logMaintenanceEvent logs a maintenance event to the database +func logMaintenanceEvent(logger *zap.Logger, userID, windowID, eventType string, enabled bool) { + event, err := models.NewEvent(userID, userID, models.EventType(eventType)) + if err != nil { + logger.Error("failed to create event", zap.Error(err)) + return + } + + defer func() { + if err := dbCon.NewEvent(event); err != nil { + logger.Error("failed to create event", zap.Error(err)) + } + }() + + var eventLog string + switch eventType { + case "maintenance_window_create": + eventLog = fmt.Sprintf("Maintenance window created by admin %s (ID: %s, Enabled: %v)", userID, windowID, enabled) + case "maintenance_window_update": + eventLog = fmt.Sprintf("Maintenance window updated by admin %s (ID: %s, Enabled: %v)", userID, windowID, enabled) + case "maintenance_window_delete": + eventLog = fmt.Sprintf("Maintenance window deleted by admin %s (ID: %s)", userID, windowID) + } + + event.SetLog(models.EventLogLevelINFO, eventLog) +} diff --git a/api/internal/pkg/pac-go-server/services/maintenance_test.go b/api/internal/pkg/pac-go-server/services/maintenance_test.go new file mode 100644 index 00000000..d039f5a8 --- /dev/null +++ b/api/internal/pkg/pac-go-server/services/maintenance_test.go @@ -0,0 +1,372 @@ +package services + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/models" + "github.com/gin-gonic/gin" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +func TestGetMaintenanceWindows(t *testing.T) { + gin.SetMode(gin.TestMode) + _, mockDBClient, _, tearDown := setUp(t) + defer tearDown() + + testcases := []struct { + name string + mockFunc func() + httpStatus int + }{ + { + name: "fetched all maintenance windows successfully", + mockFunc: func() { + mockDBClient.EXPECT().GetAllMaintenanceWindows().Return(getResource("get-all-maintenance-windows", nil).([]models.MaintenanceWindow), nil).Times(1) + }, + httpStatus: http.StatusOK, + }, + { + name: "empty maintenance windows list", + mockFunc: func() { + mockDBClient.EXPECT().GetAllMaintenanceWindows().Return([]models.MaintenanceWindow{}, nil).Times(1) + }, + httpStatus: http.StatusOK, + }, + { + name: "database error", + mockFunc: func() { + mockDBClient.EXPECT().GetAllMaintenanceWindows().Return(nil, errors.New("database error")).Times(1) + }, + httpStatus: http.StatusInternalServerError, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + tc.mockFunc() + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + req, err := http.NewRequest(http.MethodGet, "/api/v1/maintenance", nil) + if err != nil { + t.Fatal(err) + } + c.Request = req + dbCon = mockDBClient + GetMaintenanceWindows(c) + assert.Equal(t, tc.httpStatus, c.Writer.Status()) + }) + } +} + +func TestCreateMaintenanceWindow(t *testing.T) { + gin.SetMode(gin.TestMode) + _, mockDBClient, mockKCClient, tearDown := setUp(t) + defer tearDown() + + testcases := []struct { + name string + mockFunc func() + requestContext testContext + httpStatus int + request *models.MaintenanceCreateRequest + }{ + { + name: "maintenance window created successfully", + mockFunc: func() { + mockKCClient.EXPECT().GetUserID().Return("admin-user-123").Times(1) + mockDBClient.EXPECT().GetAllMaintenanceWindows().Return([]models.MaintenanceWindow{}, nil).Times(1) // No overlaps + mockDBClient.EXPECT().CreateMaintenanceWindow(gomock.Any()).Return(nil).Times(1) + mockDBClient.EXPECT().NewEvent(gomock.Any()).Return(nil).Times(1) + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusCreated, + request: getResource("create-maintenance-window-request", nil).(*models.MaintenanceCreateRequest), + }, + { + name: "invalid request body", + mockFunc: func() { + // No mock expectations as validation fails before GetUserID call + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusBadRequest, + request: &models.MaintenanceCreateRequest{}, // Empty request + }, + { + name: "end time before start time", + mockFunc: func() { + mockKCClient.EXPECT().GetUserID().Return("admin-user-123").Times(1) + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusBadRequest, + request: getResource("create-maintenance-window-invalid-time", nil).(*models.MaintenanceCreateRequest), + }, + { + name: "overlapping maintenance window", + mockFunc: func() { + mockKCClient.EXPECT().GetUserID().Return("admin-user-123").Times(1) + mockDBClient.EXPECT().GetAllMaintenanceWindows().Return(getResource("get-all-maintenance-windows", nil).([]models.MaintenanceWindow), nil).Times(1) + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusBadRequest, + request: getResource("create-maintenance-window-request", nil).(*models.MaintenanceCreateRequest), + }, + { + name: "database error", + mockFunc: func() { + mockKCClient.EXPECT().GetUserID().Return("admin-user-123").Times(1) + mockDBClient.EXPECT().GetAllMaintenanceWindows().Return([]models.MaintenanceWindow{}, nil).Times(1) + mockDBClient.EXPECT().CreateMaintenanceWindow(gomock.Any()).Return(errors.New("database error")).Times(1) + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusInternalServerError, + request: getResource("create-maintenance-window-request", nil).(*models.MaintenanceCreateRequest), + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + tc.mockFunc() + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + marshalledRequest, _ := json.Marshal(tc.request) + req, err := http.NewRequest(http.MethodPost, "/api/v1/maintenance", bytes.NewBuffer(marshalledRequest)) + if err != nil { + t.Fatal(err) + } + ctx := getContext(tc.requestContext) + c.Request = req.WithContext(ctx) + dbCon = mockDBClient + CreateMaintenanceWindow(c) + assert.Equal(t, tc.httpStatus, c.Writer.Status()) + }) + } +} + +func TestUpdateMaintenanceWindow(t *testing.T) { + gin.SetMode(gin.TestMode) + _, mockDBClient, mockKCClient, tearDown := setUp(t) + defer tearDown() + + testcases := []struct { + name string + mockFunc func() + requestContext testContext + httpStatus int + request *models.MaintenanceUpdateRequest + requestParams gin.Param + }{ + { + name: "maintenance window updated successfully", + mockFunc: func() { + mockKCClient.EXPECT().GetUserID().Return("admin-user-123").Times(1) + mockDBClient.EXPECT().GetMaintenanceWindowByID(gomock.Any()).Return(getResource("get-maintenance-window-by-id", nil).(*models.MaintenanceWindow), nil).Times(1) + mockDBClient.EXPECT().GetAllMaintenanceWindows().Return([]models.MaintenanceWindow{}, nil).Times(1) // No overlaps + mockDBClient.EXPECT().UpdateMaintenanceWindow(gomock.Any()).Return(nil).Times(1) + mockDBClient.EXPECT().NewEvent(gomock.Any()).Return(nil).Times(1) + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusOK, + request: getResource("update-maintenance-window-request", nil).(*models.MaintenanceUpdateRequest), + requestParams: gin.Param{Key: "id", Value: "507f1f77bcf86cd799439011"}, + }, + { + name: "maintenance window not found", + mockFunc: func() { + mockKCClient.EXPECT().GetUserID().Return("admin-user-123").Times(1) + mockDBClient.EXPECT().GetMaintenanceWindowByID(gomock.Any()).Return(nil, nil).Times(1) + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusNotFound, + request: getResource("update-maintenance-window-request", nil).(*models.MaintenanceUpdateRequest), + requestParams: gin.Param{Key: "id", Value: "507f1f77bcf86cd799439011"}, + }, + { + name: "invalid request body", + mockFunc: func() { + // No mock expectations as validation fails before GetUserID call + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusBadRequest, + request: nil, // Will cause JSON parsing error + requestParams: gin.Param{Key: "id", Value: "507f1f77bcf86cd799439011"}, + }, + { + name: "database error on get", + mockFunc: func() { + mockKCClient.EXPECT().GetUserID().Return("admin-user-123").Times(1) + mockDBClient.EXPECT().GetMaintenanceWindowByID(gomock.Any()).Return(nil, errors.New("database error")).Times(1) + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusInternalServerError, + request: getResource("update-maintenance-window-request", nil).(*models.MaintenanceUpdateRequest), + requestParams: gin.Param{Key: "id", Value: "507f1f77bcf86cd799439011"}, + }, + { + name: "database error on update", + mockFunc: func() { + mockKCClient.EXPECT().GetUserID().Return("admin-user-123").Times(1) + mockDBClient.EXPECT().GetMaintenanceWindowByID(gomock.Any()).Return(getResource("get-maintenance-window-by-id", nil).(*models.MaintenanceWindow), nil).Times(1) + mockDBClient.EXPECT().GetAllMaintenanceWindows().Return([]models.MaintenanceWindow{}, nil).Times(1) + mockDBClient.EXPECT().UpdateMaintenanceWindow(gomock.Any()).Return(errors.New("database error")).Times(1) + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusInternalServerError, + request: getResource("update-maintenance-window-request", nil).(*models.MaintenanceUpdateRequest), + requestParams: gin.Param{Key: "id", Value: "507f1f77bcf86cd799439011"}, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + tc.mockFunc() + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + var marshalledRequest []byte + if tc.request != nil { + marshalledRequest, _ = json.Marshal(tc.request) + } else { + marshalledRequest = []byte("invalid json") + } + req, err := http.NewRequest(http.MethodPut, "/api/v1/maintenance/"+tc.requestParams.Value, bytes.NewBuffer(marshalledRequest)) + if err != nil { + t.Fatal(err) + } + ctx := getContext(tc.requestContext) + c.Request = req.WithContext(ctx) + c.Params = gin.Params{tc.requestParams} + dbCon = mockDBClient + UpdateMaintenanceWindow(c) + assert.Equal(t, tc.httpStatus, c.Writer.Status()) + }) + } +} + +func TestDeleteMaintenanceWindow(t *testing.T) { + gin.SetMode(gin.TestMode) + _, mockDBClient, mockKCClient, tearDown := setUp(t) + defer tearDown() + + testcases := []struct { + name string + mockFunc func() + requestContext testContext + httpStatus int + requestParams gin.Param + }{ + { + name: "maintenance window deleted successfully", + mockFunc: func() { + mockKCClient.EXPECT().GetUserID().Return("admin-user-123").Times(1) + mockDBClient.EXPECT().GetMaintenanceWindowByID(gomock.Any()).Return(getResource("get-maintenance-window-by-id", nil).(*models.MaintenanceWindow), nil).Times(1) + mockDBClient.EXPECT().DeleteMaintenanceWindow(gomock.Any(), "admin-user-123", gomock.Any()).Return(nil).Times(1) + mockDBClient.EXPECT().NewEvent(gomock.Any()).Return(nil).Times(1) + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusNoContent, + requestParams: gin.Param{Key: "id", Value: "507f1f77bcf86cd799439011"}, + }, + { + name: "maintenance window not found", + mockFunc: func() { + mockKCClient.EXPECT().GetUserID().Return("admin-user-123").Times(1) + mockDBClient.EXPECT().GetMaintenanceWindowByID(gomock.Any()).Return(nil, nil).Times(1) + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusNotFound, + requestParams: gin.Param{Key: "id", Value: "507f1f77bcf86cd799439011"}, + }, + { + name: "database error on get", + mockFunc: func() { + mockKCClient.EXPECT().GetUserID().Return("admin-user-123").Times(1) + mockDBClient.EXPECT().GetMaintenanceWindowByID(gomock.Any()).Return(nil, errors.New("database error")).Times(1) + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusInternalServerError, + requestParams: gin.Param{Key: "id", Value: "507f1f77bcf86cd799439011"}, + }, + { + name: "database error on delete", + mockFunc: func() { + mockKCClient.EXPECT().GetUserID().Return("admin-user-123").Times(1) + mockDBClient.EXPECT().GetMaintenanceWindowByID(gomock.Any()).Return(getResource("get-maintenance-window-by-id", nil).(*models.MaintenanceWindow), nil).Times(1) + mockDBClient.EXPECT().DeleteMaintenanceWindow(gomock.Any(), "admin-user-123", gomock.Any()).Return(errors.New("database error")).Times(1) + }, + requestContext: formContext(customValues{ + "keycloak_hostname": "127.0.0.1", + "keycloak_access_token": "Bearer test-token", + "keycloak_realm": "test-pac", + }), + httpStatus: http.StatusInternalServerError, + requestParams: gin.Param{Key: "id", Value: "507f1f77bcf86cd799439011"}, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + tc.mockFunc() + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + req, err := http.NewRequest(http.MethodDelete, "/api/v1/maintenance/"+tc.requestParams.Value, nil) + if err != nil { + t.Fatal(err) + } + ctx := getContext(tc.requestContext) + c.Request = req.WithContext(ctx) + c.Params = gin.Params{tc.requestParams} + dbCon = mockDBClient + DeleteMaintenanceWindow(c) + assert.Equal(t, tc.httpStatus, c.Writer.Status()) + }) + } +}