diff --git a/webapp/backend/pkg/database/interface.go b/webapp/backend/pkg/database/interface.go index 15752f92..8dc19f5b 100644 --- a/webapp/backend/pkg/database/interface.go +++ b/webapp/backend/pkg/database/interface.go @@ -103,6 +103,11 @@ type DeviceRepo interface { // Attribute Override operations GetAttributeOverrides(ctx context.Context) ([]models.AttributeOverride, error) + // GetAllOverridesForDisplay returns all overrides for display in the settings UI. + // It merges DB overrides (source: "ui") with config file overrides (source: "config"), + // so users can see everything that is active. Config overrides have ID=0 and cannot + // be deleted via the UI. + GetAllOverridesForDisplay(ctx context.Context) ([]models.AttributeOverride, error) GetAttributeOverrideByID(ctx context.Context, id uint) (*models.AttributeOverride, error) SaveAttributeOverride(ctx context.Context, override *models.AttributeOverride) error DeleteAttributeOverride(ctx context.Context, id uint) error diff --git a/webapp/backend/pkg/database/migrations/m20260411000000/attribute_override.go b/webapp/backend/pkg/database/migrations/m20260411000000/attribute_override.go new file mode 100644 index 00000000..38f33eea --- /dev/null +++ b/webapp/backend/pkg/database/migrations/m20260411000000/attribute_override.go @@ -0,0 +1,8 @@ +package m20260411000000 + +// AttributeOverride is the migration-scoped struct after adding a unique +// constraint on (protocol, attribute_id, wwn). The actual migration uses raw +// SQL to remove any pre-existing duplicates before creating the unique index, +// which GORM AutoMigrate cannot do safely on its own. +// This struct is kept for reference only; the migration logic is in the +// registered Migrate function in scrutiny_repository_migrations.go. diff --git a/webapp/backend/pkg/database/mock/mock_database.go b/webapp/backend/pkg/database/mock/mock_database.go index 5de32b07..ddd285e4 100644 --- a/webapp/backend/pkg/database/mock/mock_database.go +++ b/webapp/backend/pkg/database/mock/mock_database.go @@ -127,6 +127,21 @@ func (mr *MockDeviceRepoMockRecorder) GetAttributeOverrides(ctx interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAttributeOverrides", reflect.TypeOf((*MockDeviceRepo)(nil).GetAttributeOverrides), ctx) } +// GetAllOverridesForDisplay mocks base method. +func (m *MockDeviceRepo) GetAllOverridesForDisplay(ctx context.Context) ([]models.AttributeOverride, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllOverridesForDisplay", ctx) + ret0, _ := ret[0].([]models.AttributeOverride) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllOverridesForDisplay indicates an expected call of GetAllOverridesForDisplay. +func (mr *MockDeviceRepoMockRecorder) GetAllOverridesForDisplay(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllOverridesForDisplay", reflect.TypeOf((*MockDeviceRepo)(nil).GetAllOverridesForDisplay), ctx) +} + // GetAvailableInfluxDBBuckets mocks base method. func (m *MockDeviceRepo) GetAvailableInfluxDBBuckets(ctx context.Context) ([]string, error) { m.ctrl.T.Helper() diff --git a/webapp/backend/pkg/database/scrutiny_repository_migrations.go b/webapp/backend/pkg/database/scrutiny_repository_migrations.go index 0e0f0ea8..651cb1b3 100644 --- a/webapp/backend/pkg/database/scrutiny_repository_migrations.go +++ b/webapp/backend/pkg/database/scrutiny_repository_migrations.go @@ -836,6 +836,32 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error { return tx.Create(&defaultSettings).Error }, }, + { + ID: "m20260411000000", // enforce unique constraint on (protocol, attribute_id, wwn) in attribute_overrides + Migrate: func(tx *gorm.DB) error { + // Remove any duplicate overrides, keeping the row with the lowest id + // for each (protocol, attribute_id, wwn) combination. + if err := tx.Exec(` + DELETE FROM attribute_overrides + WHERE id NOT IN ( + SELECT MIN(id) + FROM attribute_overrides + GROUP BY protocol, attribute_id, wwn + ) + `).Error; err != nil { + return fmt.Errorf("failed to remove duplicate attribute overrides: %w", err) + } + // Drop the existing non-unique composite index so we can replace it. + if err := tx.Exec("DROP INDEX IF EXISTS idx_override_lookup").Error; err != nil { + return fmt.Errorf("failed to drop old attribute_overrides index: %w", err) + } + // Create a unique composite index to prevent future duplicates. + if err := tx.Exec("CREATE UNIQUE INDEX idx_override_lookup ON attribute_overrides (protocol, attribute_id, wwn)").Error; err != nil { + return fmt.Errorf("failed to create unique attribute_overrides index: %w", err) + } + return nil + }, + }, }) if err := m.Migrate(); err != nil { diff --git a/webapp/backend/pkg/database/scrutiny_repository_overrides.go b/webapp/backend/pkg/database/scrutiny_repository_overrides.go index 19b0ebc9..44a30dc3 100644 --- a/webapp/backend/pkg/database/scrutiny_repository_overrides.go +++ b/webapp/backend/pkg/database/scrutiny_repository_overrides.go @@ -25,6 +25,60 @@ func (sr *scrutinyRepository) GetAttributeOverrideByID(ctx context.Context, id u return &override, nil } +// GetAllOverridesForDisplay returns all active overrides for display in the settings UI. +// DB overrides (source: "ui") are returned as-is. Config file overrides (source: "config") +// are synthesized into models.AttributeOverride with ID=0, so the UI can show them as +// read-only entries. DB overrides take precedence: if a DB override matches the same +// (protocol, attribute_id, wwn) as a config override, only the DB version is returned. +func (sr *scrutinyRepository) GetAllOverridesForDisplay(ctx context.Context) ([]models.AttributeOverride, error) { + dbOverrides, err := sr.GetAttributeOverrides(ctx) + if err != nil { + return nil, err + } + + configOverrides := overrides.ParseOverrides(sr.appConfig) + + // Build a set of (protocol|attribute_id|wwn) keys already covered by DB overrides. + dbKeys := make(map[string]struct{}, len(dbOverrides)) + for i := range dbOverrides { + key := dbOverrides[i].Protocol + "|" + dbOverrides[i].AttributeId + "|" + dbOverrides[i].WWN + dbKeys[key] = struct{}{} + } + + // Append config overrides that are not shadowed by a DB override. + result := make([]models.AttributeOverride, 0, len(dbOverrides)+len(configOverrides)) + result = append(result, dbOverrides...) + + for _, co := range configOverrides { + key := co.Protocol + "|" + co.AttributeId + "|" + co.WWN + if _, exists := dbKeys[key]; exists { + continue // DB override takes precedence; skip the config version + } + var warnAbove *int64 + var failAbove *int64 + if co.WarnAbove != nil { + v := *co.WarnAbove + warnAbove = &v + } + if co.FailAbove != nil { + v := *co.FailAbove + failAbove = &v + } + result = append(result, models.AttributeOverride{ + Protocol: co.Protocol, + AttributeId: co.AttributeId, + WWN: co.WWN, + Action: string(co.Action), + Status: co.Status, + WarnAbove: warnAbove, + FailAbove: failAbove, + Source: "config", + }) + } + + return result, nil +} + // GetMergedOverrides retrieves overrides from both config file and database, // merging them with database overrides taking precedence over config overrides. func (sr *scrutinyRepository) GetMergedOverrides(ctx context.Context) []overrides.AttributeOverride { diff --git a/webapp/backend/pkg/models/attribute_override.go b/webapp/backend/pkg/models/attribute_override.go index 14b458a6..0e0d75bd 100644 --- a/webapp/backend/pkg/models/attribute_override.go +++ b/webapp/backend/pkg/models/attribute_override.go @@ -18,18 +18,18 @@ type AttributeOverride struct { DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // Required: Protocol type (ATA, NVMe, SCSI) - Protocol string `json:"protocol" gorm:"not null;index:idx_override_lookup"` + Protocol string `json:"protocol" gorm:"not null;uniqueIndex:idx_override_lookup"` // Required: Attribute ID (string for all protocols) // ATA: "5", "187", etc. // ATA DevStats: "devstat_7_8" // NVMe: "media_errors", "percentage_used" // SCSI: "scsi_grown_defect_list" - AttributeId string `json:"attribute_id" gorm:"not null;index:idx_override_lookup"` + AttributeId string `json:"attribute_id" gorm:"not null;uniqueIndex:idx_override_lookup"` // Optional: Limit override to specific device by WWN // If empty, override applies to all devices - WWN string `json:"wwn,omitempty" gorm:"index:idx_override_lookup"` + WWN string `json:"wwn,omitempty" gorm:"uniqueIndex:idx_override_lookup"` // Optional: Action to take (ignore or force_status) // If not set, custom thresholds are applied diff --git a/webapp/backend/pkg/overrides/applier.go b/webapp/backend/pkg/overrides/applier.go index 19d2fd2b..6b9b7220 100644 --- a/webapp/backend/pkg/overrides/applier.go +++ b/webapp/backend/pkg/overrides/applier.go @@ -141,7 +141,9 @@ func Apply(cfg config.Interface, protocol, attributeId, wwn string) *Result { result.StatusReason = "Status forced by user configuration" } - // Custom thresholds (can be combined with force_status or standalone) + // Custom thresholds are only evaluated when action is empty (see smart.go). + // When action is force_status, these fields are populated but callers use + // mutually exclusive else-if branches, so thresholds are effectively ignored. result.WarnAbove = override.WarnAbove result.FailAbove = override.FailAbove @@ -228,7 +230,9 @@ func ApplyWithOverrides(overrideList []AttributeOverride, protocol, attributeId, result.StatusReason = "Status forced by user configuration" } - // Custom thresholds (can be combined with force_status or standalone) + // Custom thresholds are only evaluated when action is empty (see smart.go). + // When action is force_status, these fields are populated but callers use + // mutually exclusive else-if branches, so thresholds are effectively ignored. result.WarnAbove = override.WarnAbove result.FailAbove = override.FailAbove diff --git a/webapp/backend/pkg/web/handler/attribute_overrides.go b/webapp/backend/pkg/web/handler/attribute_overrides.go index e2c2da29..24e4f464 100644 --- a/webapp/backend/pkg/web/handler/attribute_overrides.go +++ b/webapp/backend/pkg/web/handler/attribute_overrides.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "regexp" "strconv" "github.com/analogj/scrutiny/webapp/backend/pkg/database" @@ -10,6 +11,9 @@ import ( "github.com/sirupsen/logrus" ) +// wwnPattern matches a valid WWN: optional 0x prefix followed by 1-16 hex digits. +var wwnPattern = regexp.MustCompile(`(?i)^(0x)?[0-9a-f]{1,16}$`) + // validProtocols defines the allowed protocol values var validProtocols = map[string]bool{ "ATA": true, @@ -31,12 +35,14 @@ var validStatuses = map[string]bool{ "failed": true, } -// GetAttributeOverrides retrieves all attribute overrides from the database +// GetAttributeOverrides retrieves all active attribute overrides for display. +// Includes both UI-created overrides (source: "ui") and config file overrides +// (source: "config"), so users can see everything that is currently active. func GetAttributeOverrides(c *gin.Context) { logger := c.MustGet("LOGGER").(*logrus.Entry) deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo) - overrides, err := deviceRepo.GetAttributeOverrides(c) + allOverrides, err := deviceRepo.GetAllOverridesForDisplay(c) if err != nil { logger.Errorln("Error retrieving attribute overrides:", err) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "Failed to retrieve overrides"}) @@ -45,10 +51,63 @@ func GetAttributeOverrides(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, - "data": overrides, + "data": allOverrides, }) } +// validateAttributeOverride checks all fields of an override and returns a +// human-readable error string, or an empty string if the override is valid. +func validateAttributeOverride(o *models.AttributeOverride) string { + if o.Protocol == "" { + return "Protocol is required" + } + if o.AttributeId == "" { + return "AttributeId is required" + } + if !validProtocols[o.Protocol] { + return "Invalid protocol. Must be ATA, NVMe, or SCSI" + } + if !validActions[o.Action] { + return "Invalid action. Must be empty, 'ignore', or 'force_status'" + } + if o.WWN != "" && !wwnPattern.MatchString(o.WWN) { + return "Invalid WWN format. Must be a hex value (e.g. 0x5000cca264eb01d7)" + } + if o.Action == "force_status" { + return validateForceStatus(o) + } + if o.Action == "" { + return validateThresholds(o) + } + return "" +} + +func validateForceStatus(o *models.AttributeOverride) string { + if o.Status == "" { + return "Status is required when action is 'force_status'" + } + if !validStatuses[o.Status] { + return "Invalid status. Must be 'passed', 'warn', or 'failed'" + } + return "" +} + +func validateThresholds(o *models.AttributeOverride) string { + if o.WarnAbove == nil && o.FailAbove == nil { + return "At least one of warn_above or fail_above is required for custom threshold overrides" + } + if o.WarnAbove != nil && *o.WarnAbove < 0 { + return "warn_above must be a non-negative value" + } + if o.FailAbove != nil && *o.FailAbove < 0 { + return "fail_above must be a non-negative value" + } + if o.WarnAbove != nil && o.FailAbove != nil && *o.WarnAbove >= *o.FailAbove { + return "warn_above must be less than fail_above" + } + return "" +} + // SaveAttributeOverride creates or updates an attribute override func SaveAttributeOverride(c *gin.Context) { logger := c.MustGet("LOGGER").(*logrus.Entry) @@ -61,40 +120,11 @@ func SaveAttributeOverride(c *gin.Context) { return } - // Validate required fields - if override.Protocol == "" { - c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "Protocol is required"}) - return - } - if override.AttributeId == "" { - c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "AttributeId is required"}) - return - } - - // Validate protocol - if !validProtocols[override.Protocol] { - c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "Invalid protocol. Must be ATA, NVMe, or SCSI"}) - return - } - - // Validate action - if !validActions[override.Action] { - c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "Invalid action. Must be empty, 'ignore', or 'force_status'"}) + if errMsg := validateAttributeOverride(&override); errMsg != "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": errMsg}) return } - // Validate status if force_status action is used - if override.Action == "force_status" { - if override.Status == "" { - c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "Status is required when action is 'force_status'"}) - return - } - if !validStatuses[override.Status] { - c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "Invalid status. Must be 'passed', 'warn', or 'failed'"}) - return - } - } - // Source is always "ui" for API-created overrides override.Source = "ui" diff --git a/webapp/backend/pkg/web/handler/attribute_overrides_test.go b/webapp/backend/pkg/web/handler/attribute_overrides_test.go new file mode 100644 index 00000000..b816dd68 --- /dev/null +++ b/webapp/backend/pkg/web/handler/attribute_overrides_test.go @@ -0,0 +1,379 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + mock_database "github.com/analogj/scrutiny/webapp/backend/pkg/database/mock" + "github.com/analogj/scrutiny/webapp/backend/pkg/models" + "github.com/analogj/scrutiny/webapp/backend/pkg/web/handler" + "github.com/gin-gonic/gin" + "github.com/golang/mock/gomock" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +// setupOverridesRouter creates a minimal Gin router wired to the attribute override handlers. +func setupOverridesRouter(t *testing.T, mockRepo *mock_database.MockDeviceRepo) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + logger := logrus.WithField("test", t.Name()) + + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("LOGGER", logger) + c.Set("DEVICE_REPOSITORY", mockRepo) + c.Next() + }) + r.GET("/api/settings/overrides", handler.GetAttributeOverrides) + r.POST("/api/settings/overrides", handler.SaveAttributeOverride) + r.DELETE("/api/settings/overrides/:id", handler.DeleteAttributeOverride) + return r +} + +// --- GetAttributeOverrides --- + +func TestGetAttributeOverrides_ReturnsEmptyList(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + mockRepo.EXPECT().GetAllOverridesForDisplay(gomock.Any()).Return([]models.AttributeOverride{}, nil) + + router := setupOverridesRouter(t, mockRepo) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/settings/overrides", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, true, response["success"]) + require.NotNil(t, response["data"]) +} + +func TestGetAttributeOverrides_ReturnsBothUIAndConfigOverrides(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + + warnVal := int64(10) + mockRepo.EXPECT().GetAllOverridesForDisplay(gomock.Any()).Return([]models.AttributeOverride{ + {Protocol: "ATA", AttributeId: "5", Action: "ignore", Source: "ui"}, + {Protocol: "NVMe", AttributeId: "media_errors", WarnAbove: &warnVal, Source: "config"}, + }, nil) + + router := setupOverridesRouter(t, mockRepo) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/settings/overrides", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, true, response["success"]) + data, ok := response["data"].([]interface{}) + require.True(t, ok) + require.Len(t, data, 2) +} + +// --- SaveAttributeOverride validation --- + +func TestSaveAttributeOverride_MissingProtocol(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"attribute_id": "5", "action": "ignore"}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, false, response["success"]) + require.Contains(t, response["error"], "Protocol") +} + +func TestSaveAttributeOverride_MissingAttributeId(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "ATA", "action": "ignore"}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestSaveAttributeOverride_InvalidProtocol(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "SATA", "attribute_id": "5", "action": "ignore"}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Contains(t, response["error"], "protocol") +} + +func TestSaveAttributeOverride_InvalidAction(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "ATA", "attribute_id": "5", "action": "unknown"}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestSaveAttributeOverride_ForceStatusMissingStatus(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "ATA", "attribute_id": "5", "action": "force_status"}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Contains(t, response["error"], "Status") +} + +func TestSaveAttributeOverride_ForceStatusInvalidStatus(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "ATA", "attribute_id": "5", "action": "force_status", "status": "unknown"}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestSaveAttributeOverride_CustomThreshold_NoThresholds(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "ATA", "attribute_id": "5", "action": ""}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Contains(t, response["error"], "warn_above or fail_above") +} + +func TestSaveAttributeOverride_CustomThreshold_NegativeWarn(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "ATA", "attribute_id": "5", "action": "", "warn_above": -1}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Contains(t, response["error"], "non-negative") +} + +func TestSaveAttributeOverride_CustomThreshold_WarnNotLessThanFail(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "ATA", "attribute_id": "5", "action": "", "warn_above": 10, "fail_above": 5}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Contains(t, response["error"], "warn_above must be less than fail_above") +} + +func TestSaveAttributeOverride_InvalidWWN(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "ATA", "attribute_id": "5", "action": "ignore", "wwn": "not-a-wwn"}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Contains(t, response["error"], "WWN") +} + +func TestSaveAttributeOverride_ValidIgnore(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + mockRepo.EXPECT().SaveAttributeOverride(gomock.Any(), gomock.Any()).Return(nil) + mockRepo.EXPECT().GetDevices(gomock.Any()).Return([]models.Device{}, nil) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "ATA", "attribute_id": "5", "action": "ignore"}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, true, response["success"]) +} + +func TestSaveAttributeOverride_ValidForceStatus(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + mockRepo.EXPECT().SaveAttributeOverride(gomock.Any(), gomock.Any()).Return(nil) + mockRepo.EXPECT().GetDevices(gomock.Any()).Return([]models.Device{}, nil) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "NVMe", "attribute_id": "media_errors", "action": "force_status", "status": "passed"}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) +} + +func TestSaveAttributeOverride_ValidCustomThreshold(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + mockRepo.EXPECT().SaveAttributeOverride(gomock.Any(), gomock.Any()).Return(nil) + mockRepo.EXPECT().GetDevices(gomock.Any()).Return([]models.Device{}, nil) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "ATA", "attribute_id": "187", "action": "", "warn_above": 5, "fail_above": 10}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) +} + +func TestSaveAttributeOverride_ValidWWN(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + mockRepo.EXPECT().SaveAttributeOverride(gomock.Any(), gomock.Any()).Return(nil) + mockRepo.EXPECT().GetDevices(gomock.Any()).Return([]models.Device{}, nil) + + router := setupOverridesRouter(t, mockRepo) + + body := strings.NewReader(`{"protocol": "ATA", "attribute_id": "5", "action": "ignore", "wwn": "0x5000cca264eb01d7"}`) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/settings/overrides", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) +} + +// --- DeleteAttributeOverride --- + +func TestDeleteAttributeOverride_InvalidID(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + + router := setupOverridesRouter(t, mockRepo) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("DELETE", "/api/settings/overrides/not-a-number", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestDeleteAttributeOverride_Success(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + mockRepo := mock_database.NewMockDeviceRepo(mockCtrl) + mockRepo.EXPECT().GetAttributeOverrideByID(gomock.Any(), uint(1)).Return(&models.AttributeOverride{ + Protocol: "ATA", AttributeId: "5", Action: "ignore", Source: "ui", + }, nil) + mockRepo.EXPECT().DeleteAttributeOverride(gomock.Any(), uint(1)).Return(nil) + mockRepo.EXPECT().GetDevices(gomock.Any()).Return([]models.Device{}, nil) + + router := setupOverridesRouter(t, mockRepo) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("DELETE", "/api/settings/overrides/1", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, true, response["success"]) +} diff --git a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts index 3d617630..a642941a 100644 --- a/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts +++ b/webapp/frontend/src/app/layout/common/dashboard-settings/dashboard-settings.component.ts @@ -100,7 +100,7 @@ export class DashboardSettingsComponent implements OnInit { newOverride: Partial = { protocol: 'ATA', attribute_id: '', - action: 'ignore' + action: '' }; // Notification URL management @@ -240,7 +240,7 @@ export class DashboardSettingsComponent implements OnInit { this.newOverride = { protocol: 'ATA', attribute_id: '', - action: 'ignore' + action: '' }; }); }