Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a06a74cc3 | ||
|
|
d6fa2c955b | ||
|
|
e6576e9080 | ||
|
|
cd10b31ab5 | ||
|
|
d1ef0b72a4 | ||
|
|
327a39964d |
No files matched your search
@@ -1902,14 +1902,15 @@ endpoints:
|
||||
|
||||
|
||||
#### Configuring Slack alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
|
||||
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
|
||||
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.slack.overrides[].*` | See `alerting.slack.*` parameters | `{}` |
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
|
||||
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
|
||||
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
|
||||
| `alerting.slack.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
|
||||
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.slack.overrides[].*` | See `alerting.slack.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -2579,6 +2580,7 @@ security:
|
||||
| `security.oidc.client-secret` | Client secret | Required `""` |
|
||||
| `security.oidc.scopes` | Scopes to request. The only scope you need is `openid`. | Required `[]` |
|
||||
| `security.oidc.allowed-subjects` | List of subjects to allow. If empty, all subjects are allowed. | `[]` |
|
||||
| `security.oidc.session-ttl` | Session time-to-live (e.g. `8h`, `1h30m`, `2h`). | `8h` |
|
||||
|
||||
```yaml
|
||||
security:
|
||||
@@ -2590,6 +2592,8 @@ security:
|
||||
scopes: ["openid"]
|
||||
# You may optionally specify a list of allowed subjects. If this is not specified, all subjects will be allowed.
|
||||
#allowed-subjects: ["johndoe@example.com"]
|
||||
# You may optionally specify a session time-to-live. If this is not specified, defaults to 8 hours.
|
||||
#session-ttl: 8h
|
||||
```
|
||||
|
||||
Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0).
|
||||
|
||||
@@ -20,7 +20,8 @@ var (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
@@ -34,6 +35,9 @@ func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
if len(override.Title) > 0 {
|
||||
cfg.Title = override.Title
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||
@@ -73,7 +77,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -111,7 +115,7 @@ type Field struct {
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||
var message, color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
@@ -138,13 +142,16 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
Text: "",
|
||||
Attachments: []Attachment{
|
||||
{
|
||||
Title: ":helmet_with_white_cross: Gatus",
|
||||
Title: cfg.Title,
|
||||
Text: message + description,
|
||||
Short: false,
|
||||
Color: color,
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(body.Attachments[0].Title) == 0 {
|
||||
body.Attachments[0].Title = ":helmet_with_white_cross: Gatus"
|
||||
}
|
||||
if len(formattedConditionResults) > 0 {
|
||||
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||
Title: "Condition results",
|
||||
|
||||
@@ -150,7 +150,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
@@ -158,7 +158,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
{
|
||||
Name: "triggered-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
@@ -175,7 +175,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
@@ -183,12 +183,20 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-group-and-custom-title",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "custom title"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\"custom title\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
@@ -199,7 +207,12 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
}
|
||||
}
|
||||
cfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)
|
||||
if err != nil {
|
||||
t.Fatal("couldn't get config:", err.Error())
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
cfg,
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
|
||||
+1
-1
@@ -514,7 +514,7 @@ func validateUniqueKeys(config *Config) error {
|
||||
|
||||
func validateSecurityConfig(config *Config) error {
|
||||
if config.Security != nil {
|
||||
if config.Security.IsValid() {
|
||||
if config.Security.ValidateAndSetDefaults() {
|
||||
logr.Debug("[config.validateSecurityConfig] Basic security configuration has been validated")
|
||||
} else {
|
||||
// If there was an attempt to configure security, then it must mean that some confidential or private
|
||||
|
||||
@@ -1850,7 +1850,7 @@ endpoints:
|
||||
if config.Security == nil {
|
||||
t.Fatal("config.Security shouldn't have been nil")
|
||||
}
|
||||
if !config.Security.IsValid() {
|
||||
if !config.Security.ValidateAndSetDefaults() {
|
||||
t.Error("Security config should've been valid")
|
||||
}
|
||||
if config.Security.Basic == nil {
|
||||
|
||||
@@ -214,30 +214,35 @@ func prettifyNumericalParameters(parameters []string, resolvedParameters []int64
|
||||
|
||||
// prettify returns a string representation of a condition with its parameters resolved between parentheses
|
||||
func prettify(parameters []string, resolvedParameters []string, operator string) string {
|
||||
// Since, in the event of an invalid path, the resolvedParameters also contain the condition itself,
|
||||
// we'll return the resolvedParameters as-is.
|
||||
if strings.HasSuffix(resolvedParameters[0], InvalidConditionElementSuffix) || strings.HasSuffix(resolvedParameters[1], InvalidConditionElementSuffix) {
|
||||
return resolvedParameters[0] + " " + operator + " " + resolvedParameters[1]
|
||||
}
|
||||
// If using the pattern function, truncate the parameter it's being compared to if said parameter is long enough
|
||||
// Handle pattern function truncation first
|
||||
if strings.HasPrefix(parameters[0], PatternFunctionPrefix) && strings.HasSuffix(parameters[0], FunctionSuffix) && len(resolvedParameters[1]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
|
||||
resolvedParameters[1] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[1])
|
||||
}
|
||||
if strings.HasPrefix(parameters[1], PatternFunctionPrefix) && strings.HasSuffix(parameters[1], FunctionSuffix) && len(resolvedParameters[0]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
|
||||
resolvedParameters[0] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[0])
|
||||
}
|
||||
// First element is a placeholder
|
||||
if parameters[0] != resolvedParameters[0] && parameters[1] == resolvedParameters[1] {
|
||||
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1]
|
||||
// Determine the state of each parameter
|
||||
leftChanged := parameters[0] != resolvedParameters[0]
|
||||
rightChanged := parameters[1] != resolvedParameters[1]
|
||||
leftInvalid := resolvedParameters[0] == parameters[0]+" "+InvalidConditionElementSuffix
|
||||
rightInvalid := resolvedParameters[1] == parameters[1]+" "+InvalidConditionElementSuffix
|
||||
// Build the output based on what was resolved
|
||||
var left, right string
|
||||
// Format left side
|
||||
if leftChanged && !leftInvalid {
|
||||
left = parameters[0] + " (" + resolvedParameters[0] + ")"
|
||||
} else if leftInvalid {
|
||||
left = resolvedParameters[0] // Already has (INVALID)
|
||||
} else {
|
||||
left = parameters[0] // Unchanged
|
||||
}
|
||||
// Second element is a placeholder
|
||||
if parameters[0] == resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
|
||||
return parameters[0] + " " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
|
||||
// Format right side
|
||||
if rightChanged && !rightInvalid {
|
||||
right = parameters[1] + " (" + resolvedParameters[1] + ")"
|
||||
} else if rightInvalid {
|
||||
right = resolvedParameters[1] // Already has (INVALID)
|
||||
} else {
|
||||
right = parameters[1] // Unchanged
|
||||
}
|
||||
// Both elements are placeholders...?
|
||||
if parameters[0] != resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
|
||||
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
|
||||
}
|
||||
// Neither elements are placeholders
|
||||
return parameters[0] + " " + operator + " " + parameters[1]
|
||||
return left + " " + operator + " " + right
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
)
|
||||
|
||||
func TestCondition_Validate(t *testing.T) {
|
||||
@@ -777,3 +779,77 @@ func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
|
||||
t.Error("condition was invalid, result should've had an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionEvaluateWithInvalidContextPlaceholder(t *testing.T) {
|
||||
// Test case: Suite endpoint with invalid context placeholder
|
||||
// This should display the original placeholder names with resolved values
|
||||
condition := Condition("[STATUS] == [CONTEXT].expected_statusz")
|
||||
result := &Result{HTTPStatus: 200}
|
||||
ctx := gontext.New(map[string]interface{}{
|
||||
// Note: expected_statusz is not in the context (typo - should be expected_status)
|
||||
"expected_status": 200,
|
||||
"max_response_time": 5000,
|
||||
})
|
||||
// Simulate suite endpoint evaluation with context
|
||||
success := condition.evaluate(result, false, ctx) // false = don't skip resolution (default)
|
||||
if success {
|
||||
t.Error("Condition should have failed because [CONTEXT].expected_statusz doesn't exist")
|
||||
}
|
||||
if len(result.ConditionResults) == 0 {
|
||||
t.Fatal("No condition results found")
|
||||
}
|
||||
actualDisplay := result.ConditionResults[0].Condition
|
||||
// The expected format should preserve the placeholder names
|
||||
expectedDisplay := "[STATUS] (200) == [CONTEXT].expected_statusz (INVALID)"
|
||||
if actualDisplay != expectedDisplay {
|
||||
t.Errorf("Incorrect condition display for failed context placeholder\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionEvaluateWithValidContextPlaceholder(t *testing.T) {
|
||||
// Test case: Suite endpoint with valid context placeholder
|
||||
condition := Condition("[STATUS] == [CONTEXT].expected_status")
|
||||
result := &Result{HTTPStatus: 200}
|
||||
ctx := gontext.New(map[string]interface{}{
|
||||
"expected_status": 200,
|
||||
})
|
||||
// Simulate suite endpoint evaluation with context
|
||||
success := condition.evaluate(result, false, ctx)
|
||||
if !success {
|
||||
t.Error("Condition should have succeeded")
|
||||
}
|
||||
if len(result.ConditionResults) == 0 {
|
||||
t.Fatal("No condition results found")
|
||||
}
|
||||
actualDisplay := result.ConditionResults[0].Condition
|
||||
// For successful conditions, just the original condition is shown
|
||||
expectedDisplay := "[STATUS] == [CONTEXT].expected_status"
|
||||
if actualDisplay != expectedDisplay {
|
||||
t.Errorf("Incorrect condition display for successful context placeholder\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionEvaluateWithMixedValidAndInvalidContext(t *testing.T) {
|
||||
// Test case: One valid placeholder, one invalid
|
||||
// Note: For numerical comparisons, invalid placeholders that can't be parsed as numbers
|
||||
// default to 0 due to sanitizeAndResolveNumericalWithContext's behavior
|
||||
condition := Condition("[RESPONSE_TIME] < [CONTEXT].invalid_key")
|
||||
result := &Result{Duration: 100 * 1000000} // 100ms in nanoseconds
|
||||
ctx := gontext.New(map[string]interface{}{
|
||||
"valid_key": 5000,
|
||||
})
|
||||
// Simulate suite endpoint evaluation with context
|
||||
success := condition.evaluate(result, false, ctx)
|
||||
if success {
|
||||
t.Error("Condition should have failed because [CONTEXT].invalid_key doesn't exist")
|
||||
}
|
||||
if len(result.ConditionResults) == 0 {
|
||||
t.Fatal("No condition results found")
|
||||
}
|
||||
actualDisplay := result.ConditionResults[0].Condition
|
||||
// For numerical comparisons, invalid context placeholders become 0
|
||||
expectedDisplay := "[RESPONSE_TIME] (100) < [CONTEXT].invalid_key (0)"
|
||||
if actualDisplay != expectedDisplay {
|
||||
t.Errorf("Incorrect condition display\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -28,7 +28,7 @@ require (
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/net v0.42.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sync v0.16.0
|
||||
golang.org/x/sync v0.17.0
|
||||
google.golang.org/api v0.242.0
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
@@ -195,8 +195,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
||||
+3
-3
@@ -26,9 +26,9 @@ type Config struct {
|
||||
gate *g8.Gate
|
||||
}
|
||||
|
||||
// IsValid returns whether the security configuration is valid or not
|
||||
func (c *Config) IsValid() bool {
|
||||
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.isValid())
|
||||
// ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values.
|
||||
func (c *Config) ValidateAndSetDefaults() bool {
|
||||
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.ValidateAndSetDefaults())
|
||||
}
|
||||
|
||||
// RegisterHandlers registers all handlers required based on the security configuration
|
||||
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestConfig_IsValid(t *testing.T) {
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
c := &Config{
|
||||
Basic: nil,
|
||||
OIDC: nil,
|
||||
}
|
||||
if c.IsValid() {
|
||||
if c.ValidateAndSetDefaults() {
|
||||
t.Error("expected empty config to be valid")
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ func TestConfig_ApplySecurityMiddleware(t *testing.T) {
|
||||
RedirectURL: "http://localhost:80/authorization-code/callback",
|
||||
Scopes: []string{"openid"},
|
||||
AllowedSubjects: []string{"user1@example.com"},
|
||||
SessionTTL: DefaultOIDCSessionTTL,
|
||||
oauth2Config: oauth2.Config{},
|
||||
verifier: nil,
|
||||
}}
|
||||
|
||||
+18
-10
@@ -13,21 +13,29 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultOIDCSessionTTL = 8 * time.Hour
|
||||
)
|
||||
|
||||
// OIDCConfig is the configuration for OIDC authentication
|
||||
type OIDCConfig struct {
|
||||
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
|
||||
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
|
||||
ClientID string `yaml:"client-id"`
|
||||
ClientSecret string `yaml:"client-secret"`
|
||||
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
||||
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
|
||||
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
|
||||
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
|
||||
ClientID string `yaml:"client-id"`
|
||||
ClientSecret string `yaml:"client-secret"`
|
||||
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
||||
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
|
||||
SessionTTL time.Duration `yaml:"session-ttl"` // e.g. 8h. Defaults to 8 hours
|
||||
|
||||
oauth2Config oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// isValid returns whether the basic security configuration is valid or not
|
||||
func (c *OIDCConfig) isValid() bool {
|
||||
// ValidateAndSetDefaults returns whether the OIDC configuration is valid and sets default values.
|
||||
func (c *OIDCConfig) ValidateAndSetDefaults() bool {
|
||||
if c.SessionTTL <= 0 {
|
||||
c.SessionTTL = DefaultOIDCSessionTTL
|
||||
}
|
||||
return len(c.IssuerURL) > 0 && len(c.RedirectURL) > 0 && strings.HasSuffix(c.RedirectURL, "/authorization-code/callback") && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
|
||||
}
|
||||
|
||||
@@ -131,12 +139,12 @@ func (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { /
|
||||
func (c *OIDCConfig) setSessionCookie(w http.ResponseWriter, idToken *oidc.IDToken) {
|
||||
// At this point, the user has been confirmed. All that's left to do is create a session.
|
||||
sessionID := uuid.NewString()
|
||||
sessions.SetWithTTL(sessionID, idToken.Subject, time.Hour)
|
||||
sessions.SetWithTTL(sessionID, idToken.Subject, c.SessionTTL)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieNameSession,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: int(time.Hour.Seconds()),
|
||||
MaxAge: int(c.SessionTTL.Seconds()),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
+22
-2
@@ -4,11 +4,12 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
)
|
||||
|
||||
func TestOIDCConfig_isValid(t *testing.T) {
|
||||
func TestOIDCConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
c := &OIDCConfig{
|
||||
IssuerURL: "https://sso.gatus.io/",
|
||||
RedirectURL: "http://localhost:80/authorization-code/callback",
|
||||
@@ -16,10 +17,14 @@ func TestOIDCConfig_isValid(t *testing.T) {
|
||||
ClientSecret: "client-secret",
|
||||
Scopes: []string{"openid"},
|
||||
AllowedSubjects: []string{"user1@example.com"},
|
||||
SessionTTL: 0, // Not set! ValidateAndSetDefaults should set it to DefaultOIDCSessionTTL
|
||||
}
|
||||
if !c.isValid() {
|
||||
if !c.ValidateAndSetDefaults() {
|
||||
t.Error("OIDCConfig should be valid")
|
||||
}
|
||||
if c.SessionTTL != DefaultOIDCSessionTTL {
|
||||
t.Error("expected SessionTTL to be set to DefaultOIDCSessionTTL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCConfig_callbackHandler(t *testing.T) {
|
||||
@@ -68,3 +73,18 @@ func TestOIDCConfig_setSessionCookie(t *testing.T) {
|
||||
t.Error("expected cookie to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCConfig_setSessionCookieWithCustomTTL(t *testing.T) {
|
||||
customTTL := 30 * time.Minute
|
||||
c := &OIDCConfig{SessionTTL: customTTL}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"})
|
||||
cookies := responseRecorder.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Error("expected cookie to be set")
|
||||
}
|
||||
sessionCookie := cookies[0]
|
||||
if sessionCookie.MaxAge != int(customTTL.Seconds()) {
|
||||
t.Errorf("expected cookie MaxAge to be %d, but was %d", int(customTTL.Seconds()), sessionCookie.MaxAge)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user