Compare commits

...
2 Commits
Author SHA1 Message Date
TwiNandGitHub 5a06a74cc3 fix(events): Retrieve newest events instead of oldest events (#1283)
Fixes #1040
2025-09-21 15:40:17 -04:00
TwiNandGitHub d6fa2c955b fix(suites): Handle invalid paths in store and update needsToReadBody to check store (#1282)
* fix(suites): Invalid path in store parameter should return an error

* Refactor

* fix(suites): Update needsToReadBody to check store mappings for body placeholders
2025-09-21 13:15:59 -04:00
6 changed files with 166 additions and 4 deletions
+9 -1
View File
@@ -565,13 +565,21 @@ func (e *Endpoint) buildHTTPRequest() *http.Request {
return request
}
// needsToReadBody checks if there's any condition that requires the response Body to be read
// needsToReadBody checks if there's any condition or store mapping that requires the response Body to be read
func (e *Endpoint) needsToReadBody() bool {
for _, condition := range e.Conditions {
if condition.hasBodyPlaceholder() {
return true
}
}
// Check store values for body placeholders
if e.Store != nil {
for _, value := range e.Store {
if strings.Contains(value, BodyPlaceholder) {
return true
}
}
}
return false
}
+34
View File
@@ -914,6 +914,40 @@ func TestEndpoint_needsToReadBody(t *testing.T) {
if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength, statusCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
// Test store configuration with body placeholder
storeWithBodyPlaceholder := map[string]string{
"token": "[BODY].accessToken",
}
if !(&Endpoint{
Conditions: []Condition{statusCondition},
Store: storeWithBodyPlaceholder,
}).needsToReadBody() {
t.Error("expected true when store has body placeholder, got false")
}
// Test store configuration without body placeholder
storeWithoutBodyPlaceholder := map[string]string{
"status": "[STATUS]",
}
if (&Endpoint{
Conditions: []Condition{statusCondition},
Store: storeWithoutBodyPlaceholder,
}).needsToReadBody() {
t.Error("expected false when store has no body placeholder, got true")
}
// Test empty store
if (&Endpoint{
Conditions: []Condition{statusCondition},
Store: map[string]string{},
}).needsToReadBody() {
t.Error("expected false when store is empty, got true")
}
// Test nil store
if (&Endpoint{
Conditions: []Condition{statusCondition},
Store: nil,
}).needsToReadBody() {
t.Error("expected false when store is nil, got true")
}
}
func TestEndpoint_needsToRetrieveDomainExpiration(t *testing.T) {
+12
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
@@ -175,10 +176,12 @@ func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result
return nil, nil
}
storedValues := make(map[string]interface{})
var extractionErrors []string
for contextKey, placeholder := range mappings {
value, err := extractValueForStorage(placeholder, result)
if err != nil {
// Continue storing other values even if one fails
extractionErrors = append(extractionErrors, fmt.Sprintf("%s: %v", contextKey, err))
storedValues[contextKey] = fmt.Sprintf("ERROR: %v", err)
continue
}
@@ -187,6 +190,10 @@ func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result
}
storedValues[contextKey] = value
}
// Return an error if any values failed to extract
if len(extractionErrors) > 0 {
return storedValues, fmt.Errorf("failed to extract values: %s", strings.Join(extractionErrors, "; "))
}
return storedValues, nil
}
@@ -197,6 +204,11 @@ func extractValueForStorage(placeholder string, result *endpoint.Result) (interf
if err != nil {
return nil, err
}
// Check if the resolution resulted in an INVALID placeholder
// This happens when a path doesn't exist (e.g., [BODY].nonexistent)
if strings.HasSuffix(resolved, " "+endpoint.InvalidConditionElementSuffix) {
return nil, fmt.Errorf("invalid path: %s", strings.TrimSuffix(resolved, " "+endpoint.InvalidConditionElementSuffix))
}
// Try to parse as number or boolean to store as proper types
// Try int first for whole numbers
if num, err := strconv.ParseInt(resolved, 10, 64); err == nil {
+45
View File
@@ -1,6 +1,7 @@
package suite
import (
"strings"
"testing"
"time"
@@ -240,6 +241,50 @@ func TestStoreResultValues(t *testing.T) {
}
}
func TestStoreResultValuesWithInvalidPath(t *testing.T) {
ctx := gontext.New(map[string]interface{}{})
result := &endpoint.Result{
HTTPStatus: 200,
Body: []byte(`{"data": {"name": "john"}}`),
}
// Define store mappings with invalid paths
mappings := map[string]string{
"valid_status": "[STATUS]",
"invalid_token": "[BODY].accessToken", // This path doesn't exist
"invalid_nested": "[BODY].user.id.invalid", // This nested path doesn't exist
}
// Store values - should return error for invalid paths
stored, err := StoreResultValues(ctx, mappings, result)
if err == nil {
t.Fatal("Expected error when storing invalid paths, got nil")
}
// Check that the error message contains information about the invalid paths
if !strings.Contains(err.Error(), "invalid_token") {
t.Errorf("Error should mention invalid_token, got: %v", err)
}
if !strings.Contains(err.Error(), "invalid path") {
t.Errorf("Error should mention 'invalid path', got: %v", err)
}
// Verify that valid values were still stored
if stored["valid_status"] != int64(200) {
t.Errorf("Expected valid_status=200, got %v", stored["valid_status"])
}
// Verify that invalid values show error messages in stored map
if !strings.Contains(stored["invalid_token"].(string), "ERROR") {
t.Errorf("Expected invalid_token to contain ERROR, got %v", stored["invalid_token"])
}
// Verify that invalid values are NOT in context
_, err = ctx.Get("invalid_token")
if err == nil {
t.Error("Invalid token should not be stored in context")
}
// Verify that valid value IS in context
val, err := ctx.Get("valid_status")
if err != nil || val != int64(200) {
t.Errorf("Expected valid_status=200 in context, got %v, err=%v", val, err)
}
}
func TestSuite_ExecuteWithAlwaysRunEndpoints(t *testing.T) {
suite := &Suite{
Name: "test-suite",
+10 -3
View File
@@ -289,6 +289,7 @@ func (s *Store) InsertEndpointResult(ep *endpoint.Endpoint, result *endpoint.Res
// Get the success value of the previous result
var lastResultSuccess bool
if lastResultSuccess, err = s.getLastEndpointResultSuccessValue(tx, endpointID); err != nil {
// Silently fail
logr.Errorf("[sql.InsertEndpointResult] Failed to retrieve outcome of previous result for endpoint with key=%s: %s", ep.Key(), err.Error())
} else {
// If we managed to retrieve the outcome of the previous result, we'll compare it with the new result.
@@ -758,13 +759,19 @@ func (s *Store) getEndpointIDGroupAndNameByKey(tx *sql.Tx, key string) (id int64
}
func (s *Store) getEndpointEventsByEndpointID(tx *sql.Tx, endpointID int64, page, pageSize int) (events []*endpoint.Event, err error) {
// We need to get the most recent events, but return them in chronological order (oldest to newest)
// First, get the most recent events using a subquery, then order them chronologically
rows, err := tx.Query(
`
SELECT event_type, event_timestamp
FROM endpoint_events
WHERE endpoint_id = $1
FROM (
SELECT event_type, event_timestamp, endpoint_event_id
FROM endpoint_events
WHERE endpoint_id = $1
ORDER BY endpoint_event_id DESC
LIMIT $2 OFFSET $3
) AS recent_events
ORDER BY endpoint_event_id ASC
LIMIT $2 OFFSET $3
`,
endpointID,
pageSize,
+56
View File
@@ -886,3 +886,59 @@ func TestStore_HasEndpointStatusNewerThan(t *testing.T) {
t.Error("expected not to have a newer status in the future")
}
}
// TestEventOrderingFix specifically tests the SQL ordering fix for issue #1040
// This test verifies that getEndpointEventsByEndpointID returns the most recent events
// in chronological order (oldest to newest)
func TestEventOrderingFix(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/test.db", false, 100, 100)
defer store.Close()
ep := &endpoint.Endpoint{
Name: "ordering-test",
Group: "test",
URL: "https://example.com",
}
// Create many events over time
baseTime := time.Now().Add(-100 * time.Hour) // Start 100 hours ago
for i := 0; i < 50; i++ {
result := &endpoint.Result{
Success: i%2 == 0, // Alternate between true/false to create events
Timestamp: baseTime.Add(time.Duration(i) * time.Hour),
}
err := store.InsertEndpointResult(ep, result)
if err != nil {
t.Fatalf("Failed to insert result %d: %v", i, err)
}
}
// Now retrieve events with pagination to test the ordering
tx, _ := store.db.Begin()
endpointID, _, _, _ := store.getEndpointIDGroupAndNameByKey(tx, ep.Key())
// Get the first page (should get the MOST RECENT events, but in chronological order)
events, err := store.getEndpointEventsByEndpointID(tx, endpointID, 1, 10)
tx.Commit()
if err != nil {
t.Fatalf("Failed to get events: %v", err)
}
if len(events) != 10 {
t.Errorf("Expected 10 events, got %d", len(events))
}
// Verify the events are in chronological order (oldest to newest)
for i := 1; i < len(events); i++ {
if events[i].Timestamp.Before(events[i-1].Timestamp) {
t.Errorf("Events not in chronological order: event %d timestamp %v is before event %d timestamp %v",
i, events[i].Timestamp, i-1, events[i-1].Timestamp)
}
}
// Verify these are the most recent events
// The last event in the returned list should be close to "now" (within the last few events we created)
lastEventTime := events[len(events)-1].Timestamp
expectedRecentTime := baseTime.Add(49 * time.Hour) // The most recent event we created
timeDiff := expectedRecentTime.Sub(lastEventTime)
if timeDiff > 10*time.Hour { // Allow some margin for events
t.Errorf("Events are not the most recent ones. Last event time: %v, expected around: %v (diff: %v)",
lastEventTime, expectedRecentTime, timeDiff)
}
t.Logf("Successfully retrieved %d most recent events in chronological order", len(events))
t.Logf("First event: %s at %v", events[0].Type, events[0].Timestamp)
t.Logf("Last event: %s at %v", events[len(events)-1].Type, events[len(events)-1].Timestamp)
}