Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "*")
Expand Down
17 changes: 17 additions & 0 deletions internal/api/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
6 changes: 1 addition & 5 deletions internal/api/v2/v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
21 changes: 0 additions & 21 deletions internal/api/v2/v2_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
269 changes: 94 additions & 175 deletions internal/api/v2/v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
},
}

Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading