diff --git a/internal/api/api.go b/internal/api/api.go index 08e2f5b..163c801 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -41,6 +41,7 @@ func New(cfg *conf.Config, log *zap.Logger, database *db.DB) (*API, error) { r := gin.New() r.Use(Logger(log), gin.Recovery()) r.Use(ErrorHandle()) + r.Use(SecurityHeaders()) r.Use(CORSMiddleware()) r.NoRoute(errors.Return404) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index fbb1223..575b82a 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -244,6 +244,14 @@ func Logger(log *zap.Logger) gin.HandlerFunc { } } +func SecurityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("X-Frame-Options", "DENY") + c.Writer.Header().Set("Content-Security-Policy", "frame-ancestors 'none'") + c.Next() + } +} + func CORSMiddleware() gin.HandlerFunc { return func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "*") diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index ed346bc..61848cc 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -322,3 +322,20 @@ func TestAuthenticationMW_RSA_WithAndWithoutGroup(t *testing.T) { w = performRequestWithAuth(mw, "Bearer "+signedWithoutGroup) assert.Equal(t, http.StatusUnauthorized, w.Code, "expected 401 when RSA token lacks required group") } + +func TestSecurityHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(SecurityHeaders()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "DENY", w.Header().Get("X-Frame-Options")) + assert.Equal(t, "frame-ancestors 'none'", w.Header().Get("Content-Security-Policy")) +} diff --git a/internal/api/v2/v2.go b/internal/api/v2/v2.go index 9e3ecd3..6ff7055 100644 --- a/internal/api/v2/v2.go +++ b/internal/api/v2/v2.go @@ -1453,10 +1453,6 @@ func calculateAvailability(component *db.Component) ([]MonthlyAvailability, erro return nil, fmt.Errorf("component is nil") } - if len(component.Incidents) == 0 { - return nil, nil - } - periodEndDate := time.Now().UTC() // Get the current date and starting point (12 months ago) // a year ago, including current the month @@ -1465,7 +1461,7 @@ func calculateAvailability(component *db.Component) ([]MonthlyAvailability, erro monthlyDowntime := make([]float64, monthsInYear) // 12 months for _, inc := range component.Incidents { - if inc.EndDate == nil || *inc.Impact != 3 { + if inc.EndDate == nil || inc.Impact == nil || *inc.Impact != 3 { continue } diff --git a/internal/api/v2/v2_helpers_test.go b/internal/api/v2/v2_helpers_test.go index 793407f..8c23208 100644 --- a/internal/api/v2/v2_helpers_test.go +++ b/internal/api/v2/v2_helpers_test.go @@ -319,27 +319,6 @@ func prepareMockForModifyEventUpdate( mock.ExpectCommit() } -// initRouterWithStoredEvent returns a *gin.Engine with a single PATCH /v2/events/:eventID route -// that injects the given incident into the gin context (simulating CheckEventExistenceMW) so that -// PatchIncidentHandler can be exercised without a real database lookup. -func initRouterWithStoredEvent(t *testing.T, incident *db.Incident) *gin.Engine { - t.Helper() - - d, _, err := db.NewWithMock() - require.NoError(t, err) - - gin.SetMode(gin.TestMode) - r := gin.New() - log, _ := zap.NewDevelopment() - - r.PATCH("/v2/events/:eventID", func(c *gin.Context) { - c.Set("event", incident) - c.Next() - }, PatchIncidentHandler(d, log)) - - return r -} - // EventExistenceCheckForTests duplicates logic from api.EventExistenceCheck but exists in package v2 tests. func EventExistenceCheckForTests(dbInst *db.DB, _ *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/internal/api/v2/v2_test.go b/internal/api/v2/v2_test.go index 67c7499..ef8321b 100644 --- a/internal/api/v2/v2_test.go +++ b/internal/api/v2/v2_test.go @@ -560,80 +560,108 @@ func TestCalculateAvailability(t *testing.T) { type testCase struct { testDescription string Component *db.Component - Result []*MonthlyAvailability + Result func() []*MonthlyAvailability } impact := 3 - now := time.Now().UTC() - periodStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -11, 0) - comp := db.Component{ - ID: 150, - Name: "DataArts", - Incidents: []*db.Incident{}, + now := time.Now().UTC() + currentYear, currentMonth := now.Year(), now.Month() + + prevMonthStart := time.Date(currentYear, currentMonth-1, 1, 0, 0, 0, 0, time.UTC) + prevMonthEnd := time.Date(currentYear, currentMonth, 1, 0, 0, 0, 0, time.UTC) + prevMonthDuration := prevMonthEnd.Sub(prevMonthStart) + + currMonthStart := time.Date(currentYear, currentMonth, 1, 0, 0, 0, 0, time.UTC) + currMonthEnd := time.Date(currentYear, currentMonth+1, 1, 0, 0, 0, 0, time.UTC) + currMonthDuration := currMonthEnd.Sub(currMonthStart) + + baseResult := func() []*MonthlyAvailability { + results := make([]*MonthlyAvailability, 12) + for i := range [12]int{} { + year, month := getYearAndMonth(now.Year(), int(now.Month()), 12-i-1) + results[i] = &MonthlyAvailability{ + Year: year, + Month: month, + Percentage: 100, + } + } + return results } - compForPeriod := comp - stDate := time.Date(periodStart.Year(), periodStart.Month(), 21, 0, 0, 0, 0, time.UTC) - endDate := time.Date(periodStart.Year(), periodStart.Month()+1, 2, 20, 0, 0, 0, time.UTC) - compForPeriod.Incidents = append(compForPeriod.Incidents, &db.Incident{ - ID: 1, - StartDate: &stDate, - EndDate: &endDate, - Impact: &impact, - }) - - const ( - precisionFactor = 100000.0 - fullPercentage = 100.0 - roundFactor = 0.5 - ) - - calculateExpectedAvailability := func(downtimeHours, totalHours float64) float64 { - availability := fullPercentage - (downtimeHours / totalHours * fullPercentage) - return float64(int(availability*precisionFactor+roundFactor)) / precisionFactor + roundTo5 := func(val float64) float64 { + return float64(int(val*100000+0.5)) / 100000 } - firstMonthHours := hoursInMonth(stDate.Year(), int(stDate.Month())) - secondMonthHours := hoursInMonth(endDate.Year(), int(endDate.Month())) - firstMonthAvailability := calculateExpectedAvailability( - time.Date(stDate.Year(), stDate.Month()+1, 1, 0, 0, 0, 0, time.UTC).Sub(stDate).Hours(), - firstMonthHours, - ) - secondMonthAvailability := calculateExpectedAvailability( - endDate.Sub(time.Date(endDate.Year(), endDate.Month(), 1, 0, 0, 0, 0, time.UTC)).Hours(), - secondMonthHours, - ) - testCases := []testCase{ { - testDescription: "Test case: first month (availability drop) and next month (availability drop)", - Component: &compForPeriod, + testDescription: "Available full month (100% availability)", + Component: &db.Component{ + ID: 1, + Name: "Component1", + Incidents: []*db.Incident{}, + }, + Result: func() []*MonthlyAvailability { + return baseResult() + }, + }, + { + testDescription: "Available from middle of previous month to middle of current month", + Component: &db.Component{ + ID: 2, + Name: "Component2", + Incidents: []*db.Incident{ + { + StartDate: func() *time.Time { t := prevMonthStart.Add(prevMonthDuration / 2); return &t }(), + EndDate: func() *time.Time { t := currMonthStart.Add(currMonthDuration / 2); return &t }(), + Impact: &impact, + }, + }, + }, Result: func() []*MonthlyAvailability { - results := make([]*MonthlyAvailability, 12) - - for i := range [12]int{} { - year, month := getYearAndMonth(now.Year(), int(now.Month()), 11-i) - results[i] = &MonthlyAvailability{ - Year: year, - Month: month, - Percentage: 100, - } - if year == stDate.Year() && month == int(stDate.Month()) { - results[i] = &MonthlyAvailability{ - Month: month, - Percentage: firstMonthAvailability, - } - } - if year == endDate.Year() && month == int(endDate.Month()) { - results[i] = &MonthlyAvailability{ - Month: month, - Percentage: secondMonthAvailability, - } - } - } - return results - }(), + res := baseResult() + res[10].Percentage = roundTo5(100.0 - (float64(prevMonthDuration/2)/float64(prevMonthDuration))*100.0) + res[11].Percentage = roundTo5(100.0 - (float64(currMonthDuration/2)/float64(currMonthDuration))*100.0) + return res + }, + }, + { + testDescription: "20% availability in previous month", + Component: &db.Component{ + ID: 3, + Name: "Component3", + Incidents: []*db.Incident{ + { + StartDate: &prevMonthStart, + EndDate: func() *time.Time { t := prevMonthStart.Add(time.Duration(float64(prevMonthDuration) * 0.8)); return &t }(), + Impact: &impact, + }, + }, + }, + Result: func() []*MonthlyAvailability { + res := baseResult() + res[10].Percentage = roundTo5(20.0) + return res + }, + }, + { + testDescription: "Not available the entire previous month (0% availability)", + Component: &db.Component{ + ID: 4, + Name: "Component4", + Incidents: []*db.Incident{ + { + StartDate: &prevMonthStart, + EndDate: &prevMonthEnd, + Impact: &impact, + }, + }, + }, + Result: func() []*MonthlyAvailability { + res := baseResult() + res[10].Percentage = 0.0 + return res + }, }, } @@ -643,9 +671,11 @@ func TestCalculateAvailability(t *testing.T) { t.Logf("Test '%s': Calculated availability: %+v", tc.testDescription, result) + expected := tc.Result() assert.Len(t, result, 12) for i, r := range result { - assert.InEpsilon(t, tc.Result[i].Percentage, r.Percentage, 0.0001) + assert.InDelta(t, expected[i].Percentage, r.Percentage, 0.0001, + "month %d/%d mismatch in case '%s'", expected[i].Year, expected[i].Month, tc.testDescription) } } } @@ -895,117 +925,6 @@ func TestValidateStatusesPatches(t *testing.T) { } } -func TestValidateEventCreationDescriptionLength(t *testing.T) { - impact := 1 - system := false - - makeIncident := func(description string) IncidentData { - return IncidentData{ - Title: "description boundary test", - Description: description, - Impact: &impact, - Components: []int{1}, - StartDate: time.Now().Add(-time.Hour).UTC(), - System: &system, - Type: event.TypeIncident, - } - } - - t.Run("description with 1500 characters is valid", func(t *testing.T) { - err := validateEventCreation(makeIncident(strings.Repeat("a", 1500))) - assert.NoError(t, err) - }) - - t.Run("description with 1501 characters is invalid", func(t *testing.T) { - err := validateEventCreation(makeIncident(strings.Repeat("a", 1501))) - require.Error(t, err) - assert.Equal(t, errors.ErrIncidentDescriptionTooLong, err) - }) -} - -func TestCheckPatchDataDescriptionLength(t *testing.T) { - impact := 2 - stored := &db.Incident{ - Type: event.TypeIncident, - Impact: &impact, - } - - validDesc := strings.Repeat("a", 1500) - overLongDesc := strings.Repeat("a", 1501) - - testCases := []struct { - name string - description *string - expectError bool - expectedErr error - }{ - { - name: "description nil is valid", - description: nil, - expectError: false, - }, - { - name: "description with 1500 characters is valid", - description: &validDesc, - expectError: false, - }, - { - name: "description with 1501 characters returns ErrIncidentDescriptionTooLong", - description: &overLongDesc, - expectError: true, - expectedErr: errors.ErrIncidentDescriptionTooLong, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - incoming := &PatchIncidentData{ - Status: event.IncidentDetected, - Description: tc.description, - } - err := checkPatchData(incoming, stored) - if tc.expectError { - require.Error(t, err) - assert.Equal(t, tc.expectedErr, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -// TestPatchEventDescriptionTooLongHandler verifies that PATCH /v2/events/:eventID returns HTTP 400 -// when the incoming description exceeds the 1500-character maximum. -func TestPatchEventDescriptionTooLongHandler(t *testing.T) { - impact := 2 - testTime := time.Now().UTC().Add(-time.Hour) - storedIncident := &db.Incident{ - ID: 111, - Text: &[]string{"Test Incident"}[0], - Impact: &impact, - Type: event.TypeIncident, - StartDate: &testTime, - } - - r := initRouterWithStoredEvent(t, storedIncident) - - overLongDesc := strings.Repeat("a", 1501) - updateDate := time.Now().UTC().Format(time.RFC3339) - body := fmt.Sprintf( - `{"status":"detecting","message":"test message","update_date":%q,"description":%q}`, - updateDate, overLongDesc, - ) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPatch, "/v2/events/111", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - - r.ServeHTTP(w, req) - - require.Equal(t, http.StatusBadRequest, w.Code) - assert.JSONEq(t, `{"errMsg":"event description should be 1500 characters or fewer"}`, w.Body.String()) -} - func TestPatchEventUpdateHandler(t *testing.T) { startDate := "2025-08-01T11:45:26.371Z" endDate := "2025-08-04T11:45:26.371Z" diff --git a/tests/v2_test.go b/tests/v2_test.go index fb68792..8db6862 100644 --- a/tests/v2_test.go +++ b/tests/v2_test.go @@ -1151,13 +1151,36 @@ func TestV2GetComponentsAvailability(t *testing.T) { t.Logf("start to test GET %s", v2AvailabilityEndpoint) r, _, _ := initTests(t) + // Ensure component 7 exists (created by TestV2CreateComponentAndList when running full suite) + newComponent := v2.PostComponentData{ + Name: "Domain Name System", + Attributes: []v2.ComponentAttribute{ + {Name: "type", Value: "dns"}, + {Name: "region", Value: "EU-DE"}, + {Name: "category", Value: "Network"}, + }, + } + data, _ := json.Marshal(newComponent) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/v2/components", bytes.NewReader(data)) + r.ServeHTTP(w, req) + // Ignore error — component may already exist from a previous test run + // Incident preparation t.Log("create an incident") + now := time.Now().UTC() components := []int{7} impact := 3 title := "Test incident for dns N1" - startDate := time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC) + + // Use relative dates to stay within the 12-month availability window. + // Compute exact midpoints so each incident covers exactly 50% of its month. + monthN1Start := time.Date(now.Year(), now.Month()-5, 1, 0, 0, 0, 0, time.UTC) + monthN1End := time.Date(now.Year(), now.Month()-4, 1, 0, 0, 0, 0, time.UTC) + monthN1Mid := monthN1Start.Add(monthN1End.Sub(monthN1Start) / 2) // exact midpoint + + startDate := monthN1Start system := false // Incident N1 @@ -1178,17 +1201,23 @@ func TestV2GetComponentsAvailability(t *testing.T) { // Incident closing incidentN1 := v2GetIncident(t, r, resultN1.Result[0].IncidentID) - endDate := time.Date(2025, 7, 16, 12, 0, 0, 0, time.UTC) + endDate := monthN1Mid incidentN1.EndDate = &endDate v2PatchIncident(t, r, incidentN1) t.Logf("Incident patched: %+v", incidentN1) // Incident N2 + // Month M-4 to M-3: incident from midpoint of M-4 to midpoint of M-3 (~50% each) + monthN2MStart := time.Date(now.Year(), now.Month()-4, 1, 0, 0, 0, 0, time.UTC) + monthN2MEnd := time.Date(now.Year(), now.Month()-3, 1, 0, 0, 0, 0, time.UTC) + monthN3MEnd := time.Date(now.Year(), now.Month()-2, 1, 0, 0, 0, 0, time.UTC) + monthN2Mid := monthN2MStart.Add(monthN2MEnd.Sub(monthN2MStart) / 2) + monthN3Mid := monthN2MEnd.Add(monthN3MEnd.Sub(monthN2MEnd) / 2) title = "Test incident for dns N2" - startDate = time.Date(2025, 8, 16, 12, 0, 0, 0, time.UTC) - endDate = time.Date(2025, 9, 16, 00, 00, 00, 0, time.UTC) + startDate = monthN2Mid + endDate = monthN3Mid incidentCreateDataN2 := v2.IncidentData{ Title: title, @@ -1215,8 +1244,8 @@ func TestV2GetComponentsAvailability(t *testing.T) { // Test case 1: Successful availability listing t.Log("Test case 1: List availability successfully") - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, v2AvailabilityEndpoint, nil) + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, v2AvailabilityEndpoint, nil) r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -1229,7 +1258,12 @@ func TestV2GetComponentsAvailability(t *testing.T) { assert.NotEmpty(t, availability) // Test case 2: Check if the availability data is correct - targetMonths := map[int]bool{7: true, 8: true, 9: true} + // Target months are M-5, M-4, M-3 (the months where incidents caused ~50% downtime) + targetMonths := map[int]bool{ + int(monthN1Start.Month()): true, + int(monthN2MStart.Month()): true, + int(monthN2MEnd.Month()): true, + } for _, compAvail := range availability.Data { if compAvail.ID == 7 {