From 2353b5d3878454b8768973284642899441cacbba Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Tue, 26 May 2026 17:44:29 +0200 Subject: [PATCH 1/5] feat: add check_http_json task for HTTP JSON endpoint assertions --- pkg/tasks/check_http_json/config.go | 286 +++++++++++ pkg/tasks/check_http_json/task.go | 728 ++++++++++++++++++++++++++++ pkg/tasks/tasks.go | 2 + 3 files changed, 1016 insertions(+) create mode 100644 pkg/tasks/check_http_json/config.go create mode 100644 pkg/tasks/check_http_json/task.go diff --git a/pkg/tasks/check_http_json/config.go b/pkg/tasks/check_http_json/config.go new file mode 100644 index 00000000..466f923e --- /dev/null +++ b/pkg/tasks/check_http_json/config.go @@ -0,0 +1,286 @@ +package checkhttpjson + +import ( + "encoding/json" + "fmt" + "math" + "strings" + "time" + + "github.com/dustin/go-humanize" + "github.com/ethpandaops/assertoor/pkg/helper" + "github.com/itchyny/gojq" +) + +const ( + // DefaultMaxResponseSize is the default maximum response body size. + DefaultMaxResponseSize = "10MB" + + // MethodHead is the HEAD HTTP method constant. + MethodHead = "HEAD" +) + +// Operator specifies the comparison operation between actual and expected values. +type Operator string + +const ( + OperatorEq Operator = "eq" + OperatorNeq Operator = "neq" + OperatorGt Operator = "gt" + OperatorGte Operator = "gte" + OperatorLt Operator = "lt" + OperatorLte Operator = "lte" + OperatorContains Operator = "contains" + OperatorNotContains Operator = "not_contains" +) + +// allowedMethods lists HTTP methods allowed for this task. +var allowedMethods = map[string]bool{ + "GET": true, + "POST": true, + "PUT": true, + "PATCH": true, + "DELETE": true, + "HEAD": true, +} + +// AssertionConfig defines a single JSON assertion to evaluate. +// Each assertion must use exactly one of: exists (for existence checks) or +// operator+value (for comparisons). Validation rejects assertions that set both or neither. +type AssertionConfig struct { + Name string `yaml:"name" json:"name" desc:"Unique assertion name."` + Query string `yaml:"query" json:"query" desc:"jq expression evaluated against the JSON response."` + Exists *bool `yaml:"exists" json:"exists,omitempty" desc:"Assert whether the query returns at least one result."` + Operator Operator `yaml:"operator" json:"operator,omitempty" desc:"Comparison operator: eq, neq, gt, gte, lt, lte, contains, not_contains."` + Value any `yaml:"value" json:"value,omitempty" desc:"Expected value for comparison."` + AllowMissing *bool `yaml:"allowMissing" json:"allowMissing,omitempty" desc:"Override missing result behavior for this assertion."` + + // Compiled jq query (not from YAML) + compiledQuery *gojq.Code +} + +// Config holds the task configuration for fetching JSON from an HTTP endpoint +// and evaluating assertions against the response. +type Config struct { + URL string `yaml:"url" json:"url" desc:"HTTP URL of the JSON endpoint."` + Method string `yaml:"method" json:"method" desc:"HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD)."` + Headers map[string]string `yaml:"headers" json:"headers" desc:"Optional HTTP request headers."` + Body any `yaml:"body" json:"body,omitempty" desc:"Request body (YAML/JSON value, JSON-encoded before sending)."` + BodyRaw string `yaml:"bodyRaw" json:"bodyRaw,omitempty" desc:"Raw request body (sent as-is, takes precedence over body)."` + ExpectStatus *int `yaml:"expectStatus" json:"expectStatus,omitempty" desc:"Expected HTTP status code."` + ExpectStatuses []int `yaml:"expectStatuses" json:"expectStatuses,omitempty" desc:"Multiple expected HTTP status codes."` + PollInterval helper.Duration `yaml:"pollInterval" json:"pollInterval" desc:"Interval between requests."` + RequestTimeout helper.Duration `yaml:"requestTimeout" json:"requestTimeout" desc:"Timeout for a single HTTP request."` + MaxResponseSize string `yaml:"maxResponseSize" json:"maxResponseSize" desc:"Maximum response body size (e.g., '10MB')."` + FailOnCheckMiss bool `yaml:"failOnCheckMiss" json:"failOnCheckMiss" desc:"If true, fail immediately when assertions are not met."` + ContinueOnPass bool `yaml:"continueOnPass" json:"continueOnPass" desc:"If true, continue checking after all assertions pass."` + Assertions []AssertionConfig `yaml:"assertions" json:"assertions" desc:"List of JSON assertions to evaluate."` + + // Parsed values (not from YAML) + maxResponseSizeBytes int64 + expectedStatuses map[int]bool + encodedBody []byte +} + +// DefaultConfig returns a Config with default values. +func DefaultConfig() Config { + return Config{ + Method: "GET", + PollInterval: helper.Duration{Duration: 10 * time.Second}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + MaxResponseSize: DefaultMaxResponseSize, + } +} + +// Validate validates the configuration and compiles jq queries. +func (c *Config) Validate() error { + if c.URL == "" { + return fmt.Errorf("url is required") + } + + // Normalize and validate method + c.Method = strings.ToUpper(c.Method) + if !allowedMethods[c.Method] { + return fmt.Errorf("invalid method %q, must be one of: GET, POST, PUT, PATCH, DELETE, HEAD", c.Method) + } + + // HEAD cannot have assertions (no response body) + if c.Method == MethodHead && len(c.Assertions) > 0 { + return fmt.Errorf("HEAD requests cannot have assertions (no response body)") + } + + // Validate status configuration + if c.ExpectStatus != nil && len(c.ExpectStatuses) > 0 { + return fmt.Errorf("cannot set both expectStatus and expectStatuses") + } + + // Build expected statuses map + c.expectedStatuses = make(map[int]bool, 8) + + switch { + case c.ExpectStatus != nil: + if !isValidHTTPStatus(*c.ExpectStatus) { + return fmt.Errorf("invalid expectStatus %d: must be between 100 and 599", *c.ExpectStatus) + } + + c.expectedStatuses[*c.ExpectStatus] = true + case len(c.ExpectStatuses) > 0: + for _, s := range c.ExpectStatuses { + if !isValidHTTPStatus(s) { + return fmt.Errorf("invalid expectStatuses value %d: must be between 100 and 599", s) + } + + c.expectedStatuses[s] = true + } + default: + c.expectedStatuses[200] = true + } + + // Validate intervals + if c.PollInterval.Duration <= 0 { + return fmt.Errorf("pollInterval must be positive") + } + + if c.RequestTimeout.Duration <= 0 { + return fmt.Errorf("requestTimeout must be positive") + } + + // Set default and parse max response size + if c.MaxResponseSize == "" { + c.MaxResponseSize = DefaultMaxResponseSize + } + + size, err := humanize.ParseBytes(c.MaxResponseSize) + if err != nil { + return fmt.Errorf("invalid maxResponseSize %q: %w", c.MaxResponseSize, err) + } + + if size == 0 { + return fmt.Errorf("maxResponseSize must be positive") + } + + if size > math.MaxInt64 { + return fmt.Errorf("maxResponseSize %q exceeds maximum allowed value", c.MaxResponseSize) + } + + c.maxResponseSizeBytes = int64(size) + + // Encode request body if present + if c.BodyRaw != "" { + c.encodedBody = []byte(c.BodyRaw) + } else if c.Body != nil { + encoded, err := json.Marshal(c.Body) + if err != nil { + return fmt.Errorf("failed to encode body as JSON: %w", err) + } + + c.encodedBody = encoded + } + + // Validate assertions + if err := c.validateAssertions(); err != nil { + return err + } + + return nil +} + +// validateAssertions validates all assertion configurations. +func (c *Config) validateAssertions() error { + seenNames := make(map[string]bool, len(c.Assertions)) + + for i := range c.Assertions { + a := &c.Assertions[i] + + if err := c.validateAssertion(i, a, seenNames); err != nil { + return err + } + + seenNames[a.Name] = true + } + + return nil +} + +// validateAssertion validates a single assertion configuration. +func (c *Config) validateAssertion(idx int, a *AssertionConfig, seenNames map[string]bool) error { + if a.Name == "" { + return fmt.Errorf("assertion[%d]: name is required", idx) + } + + if seenNames[a.Name] { + return fmt.Errorf("assertion[%d]: duplicate name %q", idx, a.Name) + } + + if a.Query == "" { + return fmt.Errorf("assertion[%d] %q: query is required", idx, a.Name) + } + + // Compile jq query + query, err := gojq.Parse(a.Query) + if err != nil { + return fmt.Errorf("assertion[%d] %q: invalid jq syntax: %w", idx, a.Name, err) + } + + code, err := gojq.Compile(query) + if err != nil { + return fmt.Errorf("assertion[%d] %q: failed to compile jq query: %w", idx, a.Name, err) + } + + a.compiledQuery = code + + // Validate assertion mode: must have exactly one of exists or operator + hasExists := a.Exists != nil + hasOperator := a.Operator != "" + + if hasExists && hasOperator { + return fmt.Errorf("assertion[%d] %q: cannot set both 'exists' and 'operator'", idx, a.Name) + } + + if !hasExists && !hasOperator { + return fmt.Errorf("assertion[%d] %q: must set either 'exists' or 'operator'", idx, a.Name) + } + + // Validate operator if set + if hasOperator { + if err := validateOperator(a.Operator); err != nil { + return fmt.Errorf("assertion[%d] %q: %w", idx, a.Name, err) + } + + // Value is required when operator is set + if a.Value == nil { + return fmt.Errorf("assertion[%d] %q: 'value' is required when 'operator' is set", idx, a.Name) + } + } + + return nil +} + +// GetMaxResponseSizeBytes returns the parsed max response size in bytes. +func (c *Config) GetMaxResponseSizeBytes() int64 { + return c.maxResponseSizeBytes +} + +// IsExpectedStatus checks if the given status code is expected. +func (c *Config) IsExpectedStatus(status int) bool { + return c.expectedStatuses[status] +} + +// GetEncodedBody returns the encoded request body. +func (c *Config) GetEncodedBody() []byte { + return c.encodedBody +} + +func validateOperator(o Operator) error { + switch o { + case OperatorEq, OperatorNeq, OperatorGt, OperatorGte, OperatorLt, OperatorLte, + OperatorContains, OperatorNotContains: + return nil + default: + return fmt.Errorf("invalid operator %q, must be one of: eq, neq, gt, gte, lt, lte, contains, not_contains", o) + } +} + +func isValidHTTPStatus(status int) bool { + return status >= 100 && status <= 599 +} diff --git a/pkg/tasks/check_http_json/task.go b/pkg/tasks/check_http_json/task.go new file mode 100644 index 00000000..0e747eaa --- /dev/null +++ b/pkg/tasks/check_http_json/task.go @@ -0,0 +1,728 @@ +package checkhttpjson + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "time" + + "github.com/ethpandaops/assertoor/pkg/types" + "github.com/ethpandaops/assertoor/pkg/vars" + "github.com/sirupsen/logrus" +) + +const ( + outputTypeObject = "object" + outputTypeInt = "int" + + // jq execution limits + maxResultsPerAssertion = 32 + queryTimeout = 5 * time.Second +) + +// Compile-time interface compliance check. +var _ types.Task = (*Task)(nil) + +var ( + TaskName = "check_http_json" + TaskDescriptor = &types.TaskDescriptor{ + Name: TaskName, + Description: "Checks HTTP JSON endpoint and evaluates assertions using jq queries.", + Category: "utility", + Config: DefaultConfig(), + Outputs: []types.TaskOutputDefinition{ + { + Name: "passedAssertions", + Type: "array", + Description: "Array of assertion names that passed.", + }, + { + Name: "failedAssertions", + Type: "array", + Description: "Array of assertion names that failed.", + }, + { + Name: "values", + Type: outputTypeObject, + Description: "Map of assertion name to latest jq result.", + }, + { + Name: "httpStatus", + Type: outputTypeInt, + Description: "Latest HTTP status code.", + }, + { + Name: "responseErrors", + Type: outputTypeInt, + Description: "Number of HTTP/read/status errors.", + }, + { + Name: "parseErrors", + Type: outputTypeInt, + Description: "Number of JSON parse errors.", + }, + { + Name: "assertionErrors", + Type: outputTypeInt, + Description: "Number of assertion evaluation errors.", + }, + }, + NewTask: NewTask, + } +) + +// Task implements the check_http_json task. +type Task struct { + ctx *types.TaskContext + options *types.TaskOptions + config Config + logger logrus.FieldLogger + httpClient *http.Client + + // State + requestCount int + responseErrors int + parseErrors int + assertionErrors int + lastHTTPStatus int +} + +// NewTask creates a new check_http_json task. +func NewTask(ctx *types.TaskContext, options *types.TaskOptions) (types.Task, error) { + return &Task{ + ctx: ctx, + options: options, + logger: ctx.Logger.GetLogger(), + }, nil +} + +// Config returns the task configuration. +func (t *Task) Config() interface{} { + return t.config +} + +// Timeout returns the task timeout. +func (t *Task) Timeout() time.Duration { + return t.options.Timeout.Duration +} + +// LoadConfig loads and validates the task configuration. +func (t *Task) LoadConfig() error { + config := DefaultConfig() + + // Parse static config + if t.options.Config != nil { + if err := t.options.Config.Unmarshal(&config); err != nil { + return fmt.Errorf("error parsing task config for %v: %w", TaskName, err) + } + } + + // Load dynamic vars + if err := t.ctx.Vars.ConsumeVars(&config, t.options.ConfigVars); err != nil { + return err + } + + // Validate config + if err := config.Validate(); err != nil { + return err + } + + t.config = config + + t.httpClient = &http.Client{ + Timeout: config.RequestTimeout.Duration, + } + + return nil +} + +// Execute runs the task. +func (t *Task) Execute(ctx context.Context) error { + for { + t.requestCount++ + + done, err := t.runCheck(ctx) + if done { + return err + } + + select { + case <-time.After(t.config.PollInterval.Duration): + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// assertionResult holds the result of evaluating a single assertion. +type assertionResult struct { + name string + passed bool + value any + err error + waiting bool +} + +// checkOutputs holds the aggregated outputs from a check. +type checkOutputs struct { + passedAssertions []string + failedAssertions []string + values map[string]any +} + +func (t *Task) runCheck(ctx context.Context) (bool, error) { + // Make HTTP request + statusCode, body, err := t.fetchJSON(ctx) + t.lastHTTPStatus = statusCode + + if err != nil { + t.responseErrors++ + t.logger.Warnf("Request error (attempt %d): %v", t.requestCount, err) + + if t.config.FailOnCheckMiss { + t.ctx.SetResult(types.TaskResultFailure) + t.setOutputs(nil) + + return true, fmt.Errorf("request failed: %w", err) + } + + t.ctx.SetResult(types.TaskResultNone) + t.setOutputs(nil) + + return false, nil + } + + // Check status code + if !t.config.IsExpectedStatus(statusCode) { + t.responseErrors++ + t.logger.Warnf("Unexpected status %d (attempt %d)", statusCode, t.requestCount) + + if t.config.FailOnCheckMiss { + t.ctx.SetResult(types.TaskResultFailure) + t.setOutputs(nil) + + return true, fmt.Errorf("unexpected status code: %d", statusCode) + } + + t.ctx.SetResult(types.TaskResultNone) + t.setOutputs(nil) + + return false, nil + } + + // Status-only check (no assertions) + if len(t.config.Assertions) == 0 { + t.logger.Infof("Status check passed: %d", statusCode) + t.ctx.SetResult(types.TaskResultSuccess) + t.setOutputs(&checkOutputs{ + passedAssertions: []string{}, + failedAssertions: []string{}, + values: make(map[string]any, 0), + }) + + if t.config.ContinueOnPass { + return false, nil + } + + return true, nil + } + + // Parse JSON body + var jsonData any + + if err := json.Unmarshal(body, &jsonData); err != nil { + t.parseErrors++ + t.logger.Warnf("JSON parse error (attempt %d): %v", t.requestCount, err) + + if t.config.FailOnCheckMiss { + t.ctx.SetResult(types.TaskResultFailure) + t.setOutputs(nil) + + return true, fmt.Errorf("JSON parse error: %w", err) + } + + t.ctx.SetResult(types.TaskResultNone) + t.setOutputs(nil) + + return false, nil + } + + // Evaluate assertions + results := make([]assertionResult, 0, len(t.config.Assertions)) + + for i := range t.config.Assertions { + result := t.evaluateAssertion(&t.config.Assertions[i], jsonData) + results = append(results, result) + + switch { + case result.err != nil: + t.assertionErrors++ + t.logger.Warnf("Assertion %q error: %v", result.name, result.err) + case result.passed: + t.logger.Debugf("Assertion %q passed (value: %v)", result.name, result.value) + case result.waiting: + t.logger.Debugf("Assertion %q waiting", result.name) + default: + t.logger.Debugf("Assertion %q failed (value: %v)", result.name, result.value) + } + } + + // Aggregate results + out := &checkOutputs{ + passedAssertions: make([]string, 0, len(results)), + failedAssertions: make([]string, 0, len(results)), + values: make(map[string]any, len(results)), + } + + hasWaiting := false + hasFailed := false + + for _, r := range results { + if r.value != nil { + out.values[r.name] = r.value + } + + switch { + case r.err != nil: + hasFailed = true + + out.failedAssertions = append(out.failedAssertions, r.name) + case r.waiting: + hasWaiting = true + case r.passed: + out.passedAssertions = append(out.passedAssertions, r.name) + default: + hasFailed = true + + out.failedAssertions = append(out.failedAssertions, r.name) + } + } + + t.setOutputs(out) + + // Determine task result + if !hasWaiting && !hasFailed { + t.ctx.SetResult(types.TaskResultSuccess) + + if t.config.ContinueOnPass { + return false, nil + } + + return true, nil + } + + if hasFailed && t.config.FailOnCheckMiss { + t.ctx.SetResult(types.TaskResultFailure) + + return true, fmt.Errorf("assertions failed: %v", out.failedAssertions) + } + + t.ctx.SetResult(types.TaskResultNone) + + return false, nil +} + +func (t *Task) fetchJSON(ctx context.Context) (statusCode int, body []byte, err error) { + var bodyReader io.Reader + if reqBody := t.config.GetEncodedBody(); len(reqBody) > 0 { + bodyReader = bytes.NewReader(reqBody) + } + + req, err := http.NewRequestWithContext(ctx, t.config.Method, t.config.URL, bodyReader) + if err != nil { + return 0, nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + for k, v := range t.config.Headers { + req.Header.Set(k, v) + } + + // Set default Content-Type if body is present and Content-Type not set + if len(t.config.GetEncodedBody()) > 0 && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := t.httpClient.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("request failed: %w", err) + } + + defer resp.Body.Close() + + // HEAD requests don't have a body + if t.config.Method == MethodHead { + return resp.StatusCode, nil, nil + } + + // Read body with size limit + maxSize := t.config.GetMaxResponseSizeBytes() + limitedReader := io.LimitReader(resp.Body, maxSize+1) + + body, err = io.ReadAll(limitedReader) + if err != nil { + return resp.StatusCode, nil, fmt.Errorf("failed to read response body: %w", err) + } + + if int64(len(body)) > maxSize { + return resp.StatusCode, nil, fmt.Errorf("response body exceeds max size (%d bytes)", maxSize) + } + + return resp.StatusCode, body, nil +} + +func (t *Task) evaluateAssertion(assertion *AssertionConfig, jsonData any) assertionResult { + result := assertionResult{name: assertion.Name} + + // Execute jq query with timeout + ctx, cancel := context.WithTimeout(context.Background(), queryTimeout) + defer cancel() + + queryResults, err := t.executeJQQuery(ctx, assertion, jsonData) + if err != nil { + result.err = err + return result + } + + // Handle exists mode + if assertion.Exists != nil { + hasNonNull := false + + for _, v := range queryResults { + if v != nil { + hasNonNull = true + + break + } + } + + result.passed = hasNonNull == *assertion.Exists + + if len(queryResults) > 0 { + result.value = queryResults[0] + } + + return result + } + + // Handle comparison mode + if len(queryResults) == 0 { + // No results - handle missing + return t.handleMissing(result, assertion) + } + + if len(queryResults) > 1 { + // Multiple results for scalar comparison + result.err = fmt.Errorf("query returned %d results, scalar comparison requires exactly one", len(queryResults)) + return result + } + + queryResult := queryResults[0] + result.value = queryResult + + if queryResult == nil { + // Null result - handle as missing + return t.handleMissing(result, assertion) + } + + // Evaluate comparison + passed, err := evaluateOperator(assertion.Operator, queryResult, assertion.Value) + if err != nil { + result.err = err + return result + } + + result.passed = passed + + return result +} + +func (t *Task) executeJQQuery(ctx context.Context, assertion *AssertionConfig, jsonData any) ([]any, error) { + if assertion.compiledQuery == nil { + return nil, fmt.Errorf("query not compiled") + } + + results := make([]any, 0, maxResultsPerAssertion) + iter := assertion.compiledQuery.RunWithContext(ctx, jsonData) + + for { + v, ok := iter.Next() + if !ok { + break + } + + if err, isErr := v.(error); isErr { + // Check if the error is due to context cancellation + if ctx.Err() != nil { + return nil, fmt.Errorf("query timeout: %w", ctx.Err()) + } + + return nil, fmt.Errorf("jq error: %w", err) + } + + results = append(results, v) + + if len(results) > maxResultsPerAssertion { + return nil, fmt.Errorf("query returned too many results (max %d)", maxResultsPerAssertion) + } + } + + // Check context after iteration completes + if ctx.Err() != nil { + return nil, fmt.Errorf("query timeout: %w", ctx.Err()) + } + + return results, nil +} + +func (t *Task) handleMissing(result assertionResult, assertion *AssertionConfig) assertionResult { + // Check assertion-level allowMissing + if assertion.AllowMissing != nil { + if *assertion.AllowMissing { + result.passed = true + } else { + result.err = fmt.Errorf("missing result and allowMissing is false") + } + + return result + } + + // Fall back to global failOnCheckMiss + if t.config.FailOnCheckMiss { + result.err = fmt.Errorf("missing result") + } else { + result.waiting = true + } + + return result +} + +func (t *Task) setOutputs(out *checkOutputs) { + if out == nil { + t.ctx.Outputs.SetVar("passedAssertions", []string{}) + t.ctx.Outputs.SetVar("failedAssertions", []string{}) + t.ctx.Outputs.SetVar("values", map[string]any{}) + } else { + if data, err := vars.GeneralizeData(out.passedAssertions); err != nil { + t.logger.Warnf("failed to generalize passedAssertions: %v", err) + } else { + t.ctx.Outputs.SetVar("passedAssertions", data) + } + + if data, err := vars.GeneralizeData(out.failedAssertions); err != nil { + t.logger.Warnf("failed to generalize failedAssertions: %v", err) + } else { + t.ctx.Outputs.SetVar("failedAssertions", data) + } + + if data, err := vars.GeneralizeData(out.values); err != nil { + t.logger.Warnf("failed to generalize values: %v", err) + } else { + t.ctx.Outputs.SetVar("values", data) + } + } + + t.ctx.Outputs.SetVar("httpStatus", t.lastHTTPStatus) + t.ctx.Outputs.SetVar("responseErrors", t.responseErrors) + t.ctx.Outputs.SetVar("parseErrors", t.parseErrors) + t.ctx.Outputs.SetVar("assertionErrors", t.assertionErrors) +} + +// evaluateOperator compares actual and expected values using the given operator. +func evaluateOperator(op Operator, actual, expected any) (bool, error) { + switch op { + case OperatorEq: + return deepEqualWithNumericCoercion(actual, expected), nil + + case OperatorNeq: + return !deepEqualWithNumericCoercion(actual, expected), nil + + case OperatorGt, OperatorGte, OperatorLt, OperatorLte: + return compareNumeric(op, actual, expected) + + case OperatorContains: + return evalContains(actual, expected) + + case OperatorNotContains: + contains, err := evalContains(actual, expected) + if err != nil { + return false, err + } + + return !contains, nil + + default: + return false, fmt.Errorf("unknown operator: %s", op) + } +} + +func compareNumeric(op Operator, actual, expected any) (bool, error) { + actualNum, ok := toFloat64(actual) + if !ok { + return false, fmt.Errorf("actual value %v (%T) is not numeric", actual, actual) + } + + expectedNum, ok := toFloat64(expected) + if !ok { + return false, fmt.Errorf("expected value %v (%T) is not numeric", expected, expected) + } + + switch op { + case OperatorGt: + return actualNum > expectedNum, nil + case OperatorGte: + return actualNum >= expectedNum, nil + case OperatorLt: + return actualNum < expectedNum, nil + case OperatorLte: + return actualNum <= expectedNum, nil + case OperatorEq, OperatorNeq, OperatorContains, OperatorNotContains: + return false, fmt.Errorf("operator %s is not a numeric operator", op) + default: + return false, fmt.Errorf("unknown operator: %s", op) + } +} + +func toFloat64(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case float32: + return float64(n), true + case int: + return float64(n), true + case int8: + return float64(n), true + case int16: + return float64(n), true + case int32: + return float64(n), true + case int64: + return float64(n), true + case uint: + return float64(n), true + case uint8: + return float64(n), true + case uint16: + return float64(n), true + case uint32: + return float64(n), true + case uint64: + return float64(n), true + default: + return 0, false + } +} + +// deepEqualWithNumericCoercion compares two values for equality, with special +// handling for numeric types. JSON parses numbers as float64, while YAML may +// parse them as int. This function ensures that numeric values are compared +// by value rather than by type (e.g., float64(42) equals int(42)). +// The comparison is recursive for maps and slices. +func deepEqualWithNumericCoercion(a, b any) bool { + // Handle nil cases + if a == nil && b == nil { + return true + } + + if a == nil || b == nil { + return false + } + + // Try numeric comparison first + aNum, aIsNum := toFloat64(a) + bNum, bIsNum := toFloat64(b) + + if aIsNum && bIsNum { + return aNum == bNum + } + + // If one is numeric and the other isn't, they're not equal + if aIsNum != bIsNum { + return false + } + + // Handle maps recursively + aMap, aIsMap := a.(map[string]any) + bMap, bIsMap := b.(map[string]any) + + if aIsMap && bIsMap { + if len(aMap) != len(bMap) { + return false + } + + for k, av := range aMap { + bv, exists := bMap[k] + if !exists || !deepEqualWithNumericCoercion(av, bv) { + return false + } + } + + return true + } + + // Handle slices recursively + aSlice, aIsSlice := a.([]any) + bSlice, bIsSlice := b.([]any) + + if aIsSlice && bIsSlice { + if len(aSlice) != len(bSlice) { + return false + } + + for i := range aSlice { + if !deepEqualWithNumericCoercion(aSlice[i], bSlice[i]) { + return false + } + } + + return true + } + + // Fall back to reflect.DeepEqual for other types (strings, bools, etc.) + return reflect.DeepEqual(a, b) +} + +func evalContains(actual, expected any) (bool, error) { + switch a := actual.(type) { + case string: + e, ok := expected.(string) + if !ok { + return false, fmt.Errorf("contains: expected string for string comparison, got %T", expected) + } + + return strings.Contains(a, e), nil + + case []any: + for _, item := range a { + if deepEqualWithNumericCoercion(item, expected) { + return true, nil + } + } + + return false, nil + + case map[string]any: + expectedMap, ok := expected.(map[string]any) + if !ok { + return false, fmt.Errorf("contains: expected object for object comparison, got %T", expected) + } + + for k, v := range expectedMap { + actualV, exists := a[k] + if !exists || !deepEqualWithNumericCoercion(actualV, v) { + return false, nil + } + } + + return true, nil + + default: + return false, fmt.Errorf("contains: unsupported type %T", actual) + } +} diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 16d066de..159f62ff 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -19,6 +19,7 @@ import ( checkethcall "github.com/ethpandaops/assertoor/pkg/tasks/check_eth_call" checkethconfig "github.com/ethpandaops/assertoor/pkg/tasks/check_eth_config" checkexecutionsyncstatus "github.com/ethpandaops/assertoor/pkg/tasks/check_execution_sync_status" + checkhttpjson "github.com/ethpandaops/assertoor/pkg/tasks/check_http_json" generateattestations "github.com/ethpandaops/assertoor/pkg/tasks/generate_attestations" generatebatchdeposits "github.com/ethpandaops/assertoor/pkg/tasks/generate_batch_deposits" generateblobtransactions "github.com/ethpandaops/assertoor/pkg/tasks/generate_blob_transactions" @@ -71,6 +72,7 @@ var AvailableTaskDescriptors = []*types.TaskDescriptor{ checkexecutionblock.TaskDescriptor, checkethcall.TaskDescriptor, checkethconfig.TaskDescriptor, + checkhttpjson.TaskDescriptor, checkexecutionsyncstatus.TaskDescriptor, generateattestations.TaskDescriptor, generatebatchdeposits.TaskDescriptor, From 07efdac1817f2fc3f87ab3ce2f6b72251795303f Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Tue, 26 May 2026 17:44:37 +0200 Subject: [PATCH 2/5] test: add comprehensive tests for check_http_json task --- pkg/tasks/check_http_json/task_test.go | 1655 ++++++++++++++++++++++++ 1 file changed, 1655 insertions(+) create mode 100644 pkg/tasks/check_http_json/task_test.go diff --git a/pkg/tasks/check_http_json/task_test.go b/pkg/tasks/check_http_json/task_test.go new file mode 100644 index 00000000..aa741b39 --- /dev/null +++ b/pkg/tasks/check_http_json/task_test.go @@ -0,0 +1,1655 @@ +package checkhttpjson + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ethpandaops/assertoor/pkg/helper" + "github.com/ethpandaops/assertoor/pkg/types" + "github.com/ethpandaops/assertoor/pkg/vars" + "github.com/sirupsen/logrus" +) + +const ( + testJSONURL = "http://localhost:8080/api" + testAssertionName = "test" + testAssertionName2 = "test2" + testInvalidOpLabel = "invalid operator" + testMethodGET = "GET" + testMethodPOST = "POST" + testQueryReady = ".ready" + testQueryStatus = ".status" + testQueryValue = ".value" + testQueryItems = ".items" + testHello = "hello" + testWorld = "world" + testHelloWorld = "hello world" + testValueKey = "value" + testReadyCheck = "ready_check" + testStatusCheck = "status_check" + testItemsExist = "items_exist" + testCheck = "check" + testStatusOK = "ok" + testCount = "count" + testService = "service" + testLatencyMS = "latency_ms" + testStatus = "status" + testID = "id" + testName = "name" + testItems = "items" + testQueryItemsAll = ".items[]" +) + +// validBaseConfig returns a config with required fields for validation to pass. +func validBaseConfig() Config { + return Config{ + Method: testMethodGET, + PollInterval: helper.Duration{Duration: 10 * time.Second}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + } +} + +func boolPtr(b bool) *bool { + return &b +} + +// ============================================================================= +// Config Validation Tests +// ============================================================================= + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + configFunc func() Config + wantErr string + }{ + { + name: "missing url", + configFunc: func() Config { return Config{} }, + wantErr: "url is required", + }, + { + name: "empty assertions allowed for status-only check", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Assertions = []AssertionConfig{} + + return c + }, + wantErr: "", + }, + { + name: "missing assertion name", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Assertions = []AssertionConfig{ + {Query: testQueryReady, Operator: OperatorEq, Value: true}, + } + + return c + }, + wantErr: "assertion[0]: name is required", + }, + { + name: "duplicate assertion names", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Assertions = []AssertionConfig{ + {Name: testAssertionName, Query: testQueryReady, Operator: OperatorEq, Value: true}, + {Name: testAssertionName, Query: testQueryStatus, Operator: OperatorEq, Value: testStatusOK}, + } + + return c + }, + wantErr: "assertion[1]: duplicate name", + }, + { + name: "missing query", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Assertions = []AssertionConfig{ + {Name: testAssertionName, Operator: OperatorEq, Value: true}, + } + + return c + }, + wantErr: "query is required", + }, + { + name: testInvalidOpLabel, + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Assertions = []AssertionConfig{ + {Name: testAssertionName, Query: testQueryValue, Operator: "invalid", Value: 0}, + } + + return c + }, + wantErr: "invalid operator", + }, + { + name: "invalid jq syntax", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Assertions = []AssertionConfig{ + {Name: testAssertionName, Query: ".value[", Operator: OperatorEq, Value: 0}, + } + + return c + }, + wantErr: "invalid jq syntax", + }, + { + name: "both exists and operator set", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Assertions = []AssertionConfig{ + {Name: testAssertionName, Query: testQueryValue, Exists: boolPtr(true), Operator: OperatorEq, Value: 0}, + } + + return c + }, + wantErr: "cannot set both 'exists' and 'operator'", + }, + { + name: "neither exists nor operator set", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Assertions = []AssertionConfig{ + {Name: testAssertionName, Query: testQueryValue}, + } + + return c + }, + wantErr: "must set either 'exists' or 'operator'", + }, + { + name: "both expectStatus and expectStatuses set", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + status := 200 + c.ExpectStatus = &status + c.ExpectStatuses = []int{200, 201} + + return c + }, + wantErr: "cannot set both expectStatus and expectStatuses", + }, + { + name: "HEAD with assertions", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Method = MethodHead + c.Assertions = []AssertionConfig{ + {Name: testAssertionName, Query: testQueryValue, Operator: OperatorEq, Value: 0}, + } + + return c + }, + wantErr: "HEAD requests cannot have assertions", + }, + { + name: "HEAD without assertions is valid", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Method = MethodHead + c.Assertions = []AssertionConfig{} + + return c + }, + wantErr: "", + }, + { + name: "invalid method", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Method = "INVALID" + + return c + }, + wantErr: "invalid method", + }, + { + name: "invalid maxResponseSize", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.MaxResponseSize = "invalid" + + return c + }, + wantErr: "invalid maxResponseSize", + }, + { + name: "valid config with multiple assertions", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Assertions = []AssertionConfig{ + {Name: testAssertionName, Query: testQueryReady, Operator: OperatorEq, Value: true}, + {Name: testAssertionName2, Query: testQueryItems, Exists: boolPtr(true)}, + } + + return c + }, + wantErr: "", + }, + { + name: "operator without value", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.Assertions = []AssertionConfig{ + {Name: testAssertionName, Query: testQueryValue, Operator: OperatorEq}, + } + + return c + }, + wantErr: "'value' is required when 'operator' is set", + }, + { + name: "invalid expectStatus below 100", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + status := 99 + c.ExpectStatus = &status + + return c + }, + wantErr: "invalid expectStatus 99: must be between 100 and 599", + }, + { + name: "invalid expectStatus above 599", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + status := 600 + c.ExpectStatus = &status + + return c + }, + wantErr: "invalid expectStatus 600: must be between 100 and 599", + }, + { + name: "invalid expectStatuses contains zero", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.ExpectStatuses = []int{200, 0, 201} + + return c + }, + wantErr: "invalid expectStatuses value 0: must be between 100 and 599", + }, + { + name: "valid expectStatuses", + configFunc: func() Config { + c := validBaseConfig() + c.URL = testJSONURL + c.ExpectStatuses = []int{200, 201, 404, 500} + + return c + }, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.configFunc() + err := cfg.Validate() + + if tt.wantErr == "" { + if err != nil { + t.Errorf("expected no error, got %v", err) + } + } else { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.wantErr) + } else if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + } + }) + } +} + +// ============================================================================= +// Operator Tests +// ============================================================================= + +func TestEvaluateOperator(t *testing.T) { + tests := []struct { + name string + op Operator + actual any + expected any + want bool + wantErr bool + }{ + // eq/neq tests + {name: "eq bool true", op: OperatorEq, actual: true, expected: true, want: true}, + {name: "eq bool false", op: OperatorEq, actual: true, expected: false, want: false}, + {name: "eq string", op: OperatorEq, actual: testHello, expected: testHello, want: true}, + {name: "eq string mismatch", op: OperatorEq, actual: testHello, expected: testWorld, want: false}, + {name: "eq number int", op: OperatorEq, actual: float64(42), expected: float64(42), want: true}, + {name: "neq bool", op: OperatorNeq, actual: true, expected: false, want: true}, + {name: "neq string", op: OperatorNeq, actual: testHello, expected: testWorld, want: true}, + + // numeric type coercion tests (JSON returns float64, YAML may return int) + {name: "eq float64 vs int", op: OperatorEq, actual: float64(42), expected: 42, want: true}, + {name: "eq int vs float64", op: OperatorEq, actual: 42, expected: float64(42), want: true}, + {name: "eq float64 vs int64", op: OperatorEq, actual: float64(100), expected: int64(100), want: true}, + {name: "neq float64 vs int different", op: OperatorNeq, actual: float64(42), expected: 43, want: true}, + {name: "neq float64 vs int same", op: OperatorNeq, actual: float64(42), expected: 42, want: false}, + + // numeric comparison tests + {name: "gt true", op: OperatorGt, actual: float64(10), expected: float64(5), want: true}, + {name: "gt false", op: OperatorGt, actual: float64(5), expected: float64(10), want: false}, + {name: "gt equal", op: OperatorGt, actual: float64(5), expected: float64(5), want: false}, + {name: "gte true greater", op: OperatorGte, actual: float64(10), expected: float64(5), want: true}, + {name: "gte true equal", op: OperatorGte, actual: float64(5), expected: float64(5), want: true}, + {name: "gte false", op: OperatorGte, actual: float64(4), expected: float64(5), want: false}, + {name: "lt true", op: OperatorLt, actual: float64(5), expected: float64(10), want: true}, + {name: "lt false", op: OperatorLt, actual: float64(10), expected: float64(5), want: false}, + {name: "lte true less", op: OperatorLte, actual: float64(5), expected: float64(10), want: true}, + {name: "lte true equal", op: OperatorLte, actual: float64(5), expected: float64(5), want: true}, + {name: "lte false", op: OperatorLte, actual: float64(10), expected: float64(5), want: false}, + + // numeric type coercion + {name: "gt int types", op: OperatorGt, actual: 10, expected: 5, want: true}, + {name: "gt int vs float", op: OperatorGt, actual: float64(10), expected: 5, want: true}, + + // type mismatch for numeric ops + {name: "gt string error", op: OperatorGt, actual: testHello, expected: float64(5), wantErr: true}, + {name: "gt expected string error", op: OperatorGt, actual: float64(5), expected: testHello, wantErr: true}, + + // contains tests + {name: "contains string", op: OperatorContains, actual: testHelloWorld, expected: testWorld, want: true}, + {name: "contains string miss", op: OperatorContains, actual: testHelloWorld, expected: "foo", want: false}, + {name: "contains array", op: OperatorContains, actual: []any{"a", "b", "c"}, expected: "b", want: true}, + {name: "contains array miss", op: OperatorContains, actual: []any{"a", "b", "c"}, expected: "d", want: false}, + {name: "contains object", op: OperatorContains, actual: map[string]any{"a": 1, "b": 2}, expected: map[string]any{"a": 1}, want: true}, + {name: "contains object miss", op: OperatorContains, actual: map[string]any{"a": 1, "b": 2}, expected: map[string]any{"c": 3}, want: false}, + + // contains with numeric type coercion + {name: "contains array float64 vs int", op: OperatorContains, actual: []any{float64(1), float64(2), float64(3)}, expected: 2, want: true}, + {name: "contains object float64 vs int", op: OperatorContains, actual: map[string]any{testCount: float64(42)}, expected: map[string]any{testCount: 42}, want: true}, + + // recursive numeric coercion in nested structures + { + name: "eq nested map float64 vs int", + op: OperatorEq, + actual: map[string]any{ + testService: map[string]any{ + testLatencyMS: float64(5), + testStatus: testStatusOK, + }, + }, + expected: map[string]any{ + testService: map[string]any{ + testLatencyMS: 5, + testStatus: testStatusOK, + }, + }, + want: true, + }, + { + name: "eq deeply nested numeric", + op: OperatorEq, + actual: map[string]any{ + "outer": map[string]any{ + "inner": map[string]any{ + testCount: float64(42), + }, + }, + }, + expected: map[string]any{ + "outer": map[string]any{ + "inner": map[string]any{ + testCount: 42, + }, + }, + }, + want: true, + }, + { + name: "eq nested array float64 vs int", + op: OperatorEq, + actual: []any{float64(1), float64(2), []any{float64(3), float64(4)}}, + expected: []any{1, 2, []any{3, 4}}, + want: true, + }, + { + name: "eq array of objects with numeric coercion", + op: OperatorEq, + actual: []any{ + map[string]any{testID: float64(1), testName: "a"}, + map[string]any{testID: float64(2), testName: "b"}, + }, + expected: []any{ + map[string]any{testID: 1, testName: "a"}, + map[string]any{testID: 2, testName: "b"}, + }, + want: true, + }, + { + name: "neq nested map different values", + op: OperatorNeq, + actual: map[string]any{ + testService: map[string]any{testLatencyMS: float64(5)}, + }, + expected: map[string]any{ + testService: map[string]any{testLatencyMS: 10}, + }, + want: true, + }, + + // not_contains tests + {name: "not_contains string", op: OperatorNotContains, actual: testHelloWorld, expected: "foo", want: true}, + {name: "not_contains string miss", op: OperatorNotContains, actual: testHelloWorld, expected: testWorld, want: false}, + + // contains type mismatch + {name: "contains string with int", op: OperatorContains, actual: testHello, expected: 123, wantErr: true}, + {name: "contains int error", op: OperatorContains, actual: 123, expected: 1, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := evaluateOperator(tt.op, tt.actual, tt.expected) + + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } + + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + + return + } + + if got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +// ============================================================================= +// HTTP Server Tests +// ============================================================================= + +func newTestTaskWithContext(cfg *Config) (*Task, *types.TaskResult) { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + outputs := vars.NewVariables(nil) + + var lastResult types.TaskResult + + ctx := &types.TaskContext{ + Outputs: outputs, + SetResult: func(r types.TaskResult) { lastResult = r }, + ReportProgress: func(_ float64, _ string) {}, + } + + var config Config + if cfg != nil { + config = *cfg + } + + // Ensure maxResponseSizeBytes has a default for tests that don't call Validate + // Use decimal value (10MB = 10,000,000) consistent with humanize.ParseBytes + if config.maxResponseSizeBytes == 0 { + config.maxResponseSizeBytes = 10 * 1000 * 1000 + } + + task := &Task{ + ctx: ctx, + config: config, + logger: logger, + httpClient: &http.Client{ + Timeout: config.RequestTimeout.Duration, + }, + } + + return task, &lastResult +} + +func TestTask_StatusOnlyCheck(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: testMethodGET, + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + Assertions: []AssertionConfig{}, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, result := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + if *result != types.TaskResultSuccess { + t.Errorf("expected success, got %v", *result) + } +} + +func TestTask_HEADRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != MethodHead { + t.Errorf("expected HEAD request, got %s", r.Method) + } + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: MethodHead, + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + Assertions: []AssertionConfig{}, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, result := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + if *result != types.TaskResultSuccess { + t.Errorf("expected success, got %v", *result) + } +} + +func TestTask_UnexpectedStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: testMethodGET, + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + FailOnCheckMiss: true, + Assertions: []AssertionConfig{}, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, result := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err == nil { + t.Error("expected error for unexpected status") + } + + if *result != types.TaskResultFailure { + t.Errorf("expected failure, got %v", *result) + } +} + +func TestTask_MultipleExpectedStatuses(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: testMethodGET, + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + ExpectStatuses: []int{200, 201, 202}, + Assertions: []AssertionConfig{}, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, result := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + if *result != types.TaskResultSuccess { + t.Errorf("expected success for status 201, got %v", *result) + } +} + +func TestTask_JSONAssertions(t *testing.T) { + jsonResponse := map[string]any{ + "ready": true, + "status": testStatusOK, + "count": float64(42), + testItems: []any{"a", "b", "c"}, + "nested": map[string]any{ + testValueKey: float64(100), + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(jsonResponse) + })) + defer server.Close() + + tests := []struct { + name string + assertions []AssertionConfig + wantPass bool + }{ + { + name: "eq bool passes", + assertions: []AssertionConfig{ + {Name: testReadyCheck, Query: testQueryReady, Operator: OperatorEq, Value: true}, + }, + wantPass: true, + }, + { + name: "eq string passes", + assertions: []AssertionConfig{ + {Name: testStatusCheck, Query: testQueryStatus, Operator: OperatorEq, Value: testStatusOK}, + }, + wantPass: true, + }, + { + name: "gt number passes", + assertions: []AssertionConfig{ + {Name: "count_check", Query: ".count", Operator: OperatorGt, Value: float64(40)}, + }, + wantPass: true, + }, + { + name: "exists true passes", + assertions: []AssertionConfig{ + {Name: testItemsExist, Query: testQueryItems, Exists: boolPtr(true)}, + }, + wantPass: true, + }, + { + name: "exists false passes for missing", + assertions: []AssertionConfig{ + {Name: "missing_check", Query: ".nonexistent", Exists: boolPtr(false)}, + }, + wantPass: true, + }, + { + name: "nested query passes", + assertions: []AssertionConfig{ + {Name: "nested_check", Query: ".nested.value", Operator: OperatorEq, Value: float64(100)}, + }, + wantPass: true, + }, + { + name: "array length check", + assertions: []AssertionConfig{ + // gojq returns int for length, so we use int for comparison + {Name: "items_length", Query: ".items | length", Operator: OperatorEq, Value: 3}, + }, + wantPass: true, + }, + { + name: "eq fails on mismatch", + assertions: []AssertionConfig{ + {Name: "wrong_status", Query: testQueryStatus, Operator: OperatorEq, Value: "error"}, + }, + wantPass: false, + }, + { + name: "exists true fails for missing", + assertions: []AssertionConfig{ + {Name: "missing_required", Query: ".nonexistent", Exists: boolPtr(true)}, + }, + wantPass: false, + }, + { + name: "multiple assertions all pass", + assertions: []AssertionConfig{ + {Name: testReadyCheck, Query: testQueryReady, Operator: OperatorEq, Value: true}, + {Name: testStatusCheck, Query: testQueryStatus, Operator: OperatorEq, Value: testStatusOK}, + {Name: testItemsExist, Query: testQueryItems, Exists: boolPtr(true)}, + }, + wantPass: true, + }, + { + name: "multiple assertions one fails", + assertions: []AssertionConfig{ + {Name: testReadyCheck, Query: testQueryReady, Operator: OperatorEq, Value: true}, + {Name: "wrong_check", Query: testQueryStatus, Operator: OperatorEq, Value: "error"}, + }, + wantPass: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Config{ + URL: server.URL, + Method: testMethodGET, + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + FailOnCheckMiss: true, + Assertions: tt.assertions, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, result := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + + if tt.wantPass { + if err != nil { + t.Errorf("expected pass, got error: %v", err) + } + + if *result != types.TaskResultSuccess { + t.Errorf("expected success, got %v", *result) + } + } else { + if err == nil { + t.Error("expected error for failed assertion") + } + + if *result != types.TaskResultFailure { + t.Errorf("expected failure, got %v", *result) + } + } + }) + } +} + +func TestTask_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`invalid json`)) + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: testMethodGET, + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + FailOnCheckMiss: true, + Assertions: []AssertionConfig{ + {Name: testCheck, Query: testQueryValue, Operator: OperatorEq, Value: true}, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, _ := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err == nil { + t.Error("expected error for invalid JSON") + } + + if !strings.Contains(err.Error(), "JSON parse error") { + t.Errorf("expected JSON parse error, got: %v", err) + } + + if task.parseErrors != 1 { + t.Errorf("expected parseErrors=1, got %d", task.parseErrors) + } +} + +func TestTask_EmptyBodyWithAssertions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + // Empty body + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: testMethodGET, + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + FailOnCheckMiss: true, + Assertions: []AssertionConfig{ + {Name: testCheck, Query: testQueryValue, Operator: OperatorEq, Value: true}, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, _ := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err == nil { + t.Error("expected error for empty body with assertions") + } + + if task.parseErrors != 1 { + t.Errorf("expected parseErrors=1, got %d", task.parseErrors) + } +} + +func TestTask_RequestBody(t *testing.T) { + var receivedBody map[string]any + + var receivedContentType string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedContentType = r.Header.Get("Content-Type") + + _ = json.NewDecoder(r.Body).Decode(&receivedBody) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success": true}`)) + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: testMethodPOST, + Body: map[string]any{"key": testValueKey}, + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + Assertions: []AssertionConfig{ + {Name: "success", Query: ".success", Operator: OperatorEq, Value: true}, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, _ := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if receivedContentType != "application/json" { + t.Errorf("expected Content-Type application/json, got %s", receivedContentType) + } + + if receivedBody["key"] != testValueKey { + t.Errorf("expected body key=value, got %v", receivedBody) + } +} + +func TestTask_BodyRawTakesPrecedence(t *testing.T) { + var receivedBody string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 1024) + n, _ := r.Body.Read(buf) + receivedBody = string(buf[:n]) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: testMethodPOST, + Body: map[string]any{"ignored": true}, + BodyRaw: "raw body content", + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + Assertions: []AssertionConfig{}, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, _ := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if receivedBody != "raw body content" { + t.Errorf("expected raw body content, got %s", receivedBody) + } +} + +func TestTask_CustomHeaders(t *testing.T) { + var receivedAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: testMethodGET, + Headers: map[string]string{"Authorization": "Bearer token123"}, + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + Assertions: []AssertionConfig{}, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, _ := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if receivedAuth != "Bearer token123" { + t.Errorf("expected Authorization header, got %s", receivedAuth) + } +} + +func TestTask_MaxResponseSize(t *testing.T) { + largeBody := strings.Repeat("x", 1024*1024) // 1MB + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(largeBody)) + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: testMethodGET, + MaxResponseSize: "1KB", + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + FailOnCheckMiss: true, + Assertions: []AssertionConfig{ + {Name: testCheck, Query: ".", Exists: boolPtr(true)}, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, _ := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err == nil { + t.Error("expected error for oversized response") + } + + if !strings.Contains(err.Error(), "exceeds max size") { + t.Errorf("expected max size error, got: %v", err) + } +} + +func TestTask_AllowMissingOverride(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"existing": true}`)) + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: testMethodGET, + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + FailOnCheckMiss: true, + Assertions: []AssertionConfig{ + {Name: "missing_allowed", Query: ".missing", Operator: OperatorEq, Value: true, AllowMissing: boolPtr(true)}, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, result := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err != nil { + t.Errorf("expected pass with allowMissing, got error: %v", err) + } + + if *result != types.TaskResultSuccess { + t.Errorf("expected success with allowMissing, got %v", *result) + } +} + +func TestTask_MultipleQueryResults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"items": [1, 2, 3]}`)) + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: testMethodGET, + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + FailOnCheckMiss: true, + Assertions: []AssertionConfig{ + // This query returns multiple results + {Name: "items_check", Query: testQueryItemsAll, Operator: OperatorEq, Value: float64(1)}, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, _ := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err == nil { + t.Error("expected error for multiple query results") + } + + // The error is wrapped at top level as "assertions failed" + if !strings.Contains(err.Error(), "assertions failed") { + t.Errorf("expected assertions failed error, got: %v", err) + } +} + +func TestTask_ExistsWithMultipleResults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"items": [1, 2, 3]}`)) + })) + defer server.Close() + + cfg := Config{ + URL: server.URL, + Method: testMethodGET, + PollInterval: helper.Duration{Duration: 100 * time.Millisecond}, + RequestTimeout: helper.Duration{Duration: 5 * time.Second}, + Assertions: []AssertionConfig{ + // exists mode allows multiple results + {Name: testItemsExist, Query: testQueryItemsAll, Exists: boolPtr(true)}, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("config validation failed: %v", err) + } + + task, result := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err != nil { + t.Errorf("expected pass for exists with multiple results, got error: %v", err) + } + + if *result != types.TaskResultSuccess { + t.Errorf("expected success, got %v", *result) + } +} + +// ============================================================================= +// Full-Cycle Integration Tests +// ============================================================================= +// These tests verify the complete flow from DefaultConfig() through Validate() +// to execution, mirroring how the task is used in production. + +func TestIntegration_BasicAssertion(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ready": true, "version": "1.0.0", "count": 42}`)) + })) + defer server.Close() + + // Start from DefaultConfig (like production) + cfg := DefaultConfig() + cfg.URL = server.URL + cfg.Assertions = []AssertionConfig{ + { + Name: "service_ready", + Query: ".ready", + Operator: OperatorEq, + Value: true, + }, + } + + // Validate (like production) + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() failed: %v", err) + } + + // Verify maxResponseSizeBytes was set by Validate + if cfg.GetMaxResponseSizeBytes() == 0 { + t.Fatal("maxResponseSizeBytes should be set after Validate()") + } + + // Create task with context (like production) + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + outputs := vars.NewVariables(nil) + + var lastResult types.TaskResult + + ctx := &types.TaskContext{ + Outputs: outputs, + SetResult: func(r types.TaskResult) { lastResult = r }, + ReportProgress: func(_ float64, _ string) {}, + } + + task := &Task{ + ctx: ctx, + config: cfg, + logger: logger, + httpClient: &http.Client{ + Timeout: cfg.RequestTimeout.Duration, + }, + } + + // Execute + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(bgCtx) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if lastResult != types.TaskResultSuccess { + t.Errorf("result = %v, want TaskResultSuccess", lastResult) + } + + // Verify outputs + passed := outputs.GetVar("passedAssertions") + if passed == nil { + t.Fatal("passedAssertions output not set") + } + + passedList, ok := passed.([]any) + if !ok { + t.Fatalf("passedAssertions type = %T, want []any", passed) + } + + if len(passedList) != 1 || passedList[0] != "service_ready" { + t.Errorf("passedAssertions = %v, want [service_ready]", passedList) + } +} + +func TestIntegration_ExistsMode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "proof_types": [ + {"proof_type": "reth-zisk", "can_verify": true}, + {"proof_type": "other", "can_verify": false} + ] + }`)) + })) + defer server.Close() + + cfg := DefaultConfig() + cfg.URL = server.URL + cfg.Assertions = []AssertionConfig{ + { + Name: "reth_zisk_verifier_loaded", + Query: `.proof_types[] | select(.proof_type == "reth-zisk" and .can_verify == true)`, + Exists: boolPtr(true), + }, + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() failed: %v", err) + } + + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + outputs := vars.NewVariables(nil) + + var lastResult types.TaskResult + + ctx := &types.TaskContext{ + Outputs: outputs, + SetResult: func(r types.TaskResult) { lastResult = r }, + ReportProgress: func(_ float64, _ string) {}, + } + + task := &Task{ + ctx: ctx, + config: cfg, + logger: logger, + httpClient: &http.Client{ + Timeout: cfg.RequestTimeout.Duration, + }, + } + + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(bgCtx) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if lastResult != types.TaskResultSuccess { + t.Errorf("result = %v, want TaskResultSuccess", lastResult) + } +} + +func TestIntegration_MultipleAssertions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "status": "healthy", + "services": { + "database": {"connected": true, "latency_ms": 5}, + "cache": {"connected": true, "latency_ms": 2} + }, + "uptime_seconds": 86400 + }`)) + })) + defer server.Close() + + cfg := DefaultConfig() + cfg.URL = server.URL + cfg.Assertions = []AssertionConfig{ + { + Name: "status_healthy", + Query: ".status", + Operator: OperatorEq, + Value: "healthy", + }, + { + Name: "db_connected", + Query: ".services.database.connected", + Operator: OperatorEq, + Value: true, + }, + { + Name: "db_latency_ok", + Query: ".services.database.latency_ms", + Operator: OperatorLt, + Value: float64(10), + }, + { + Name: "uptime_sufficient", + Query: ".uptime_seconds", + Operator: OperatorGte, + Value: float64(3600), + }, + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() failed: %v", err) + } + + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + outputs := vars.NewVariables(nil) + + var lastResult types.TaskResult + + ctx := &types.TaskContext{ + Outputs: outputs, + SetResult: func(r types.TaskResult) { lastResult = r }, + ReportProgress: func(_ float64, _ string) {}, + } + + task := &Task{ + ctx: ctx, + config: cfg, + logger: logger, + httpClient: &http.Client{ + Timeout: cfg.RequestTimeout.Duration, + }, + } + + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(bgCtx) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if lastResult != types.TaskResultSuccess { + t.Errorf("result = %v, want TaskResultSuccess", lastResult) + } + + // Verify all four passed + passed := outputs.GetVar("passedAssertions") + passedList, ok := passed.([]any) + + if !ok { + t.Fatalf("passedAssertions type = %T, want []any", passed) + } + + if len(passedList) != 4 { + t.Errorf("passedAssertions count = %d, want 4", len(passedList)) + } + + // Verify values output + values := outputs.GetVar("values") + valuesMap, ok := values.(map[string]any) + + if !ok { + t.Fatalf("values type = %T, want map[string]any", values) + } + + if v := valuesMap["status_healthy"]; v != "healthy" { + t.Errorf("values[status_healthy] = %v, want 'healthy'", v) + } +} + +func TestIntegration_StatusOnlyCheck(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + // Return non-JSON response - status-only check should still pass + _, _ = w.Write([]byte("OK")) + })) + defer server.Close() + + cfg := DefaultConfig() + cfg.URL = server.URL + cfg.Assertions = []AssertionConfig{} // Empty - status-only check + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() failed: %v", err) + } + + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + outputs := vars.NewVariables(nil) + + var lastResult types.TaskResult + + ctx := &types.TaskContext{ + Outputs: outputs, + SetResult: func(r types.TaskResult) { lastResult = r }, + ReportProgress: func(_ float64, _ string) {}, + } + + task := &Task{ + ctx: ctx, + config: cfg, + logger: logger, + httpClient: &http.Client{ + Timeout: cfg.RequestTimeout.Duration, + }, + } + + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(bgCtx) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if lastResult != types.TaskResultSuccess { + t.Errorf("result = %v, want TaskResultSuccess", lastResult) + } + + // Verify httpStatus output + httpStatus := outputs.GetVar("httpStatus") + if httpStatus != 200 { + t.Errorf("httpStatus = %v, want 200", httpStatus) + } +} + +// ============================================================================= +// Max Results and Timeout Tests +// ============================================================================= + +func TestTask_MaxResultsExactly32Succeeds(t *testing.T) { + // Generate JSON with exactly 32 items + items := make([]int, 32) + for i := range items { + items[i] = i + 1 + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{testItems: items}) + })) + defer server.Close() + + cfg := DefaultConfig() + cfg.URL = server.URL + cfg.Assertions = []AssertionConfig{ + { + Name: "items_exist", + Query: ".items[]", + Exists: boolPtr(true), + }, + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() failed: %v", err) + } + + task, result := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err != nil { + t.Errorf("expected success with 32 results, got error: %v", err) + } + + if *result != types.TaskResultSuccess { + t.Errorf("result = %v, want TaskResultSuccess", *result) + } +} + +func TestTask_MaxResults33Fails(t *testing.T) { + // Generate JSON with 33 items (exceeds max of 32) + items := make([]int, 33) + for i := range items { + items[i] = i + 1 + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{testItems: items}) + })) + defer server.Close() + + cfg := DefaultConfig() + cfg.URL = server.URL + cfg.FailOnCheckMiss = true + cfg.Assertions = []AssertionConfig{ + { + Name: "items_exist", + Query: ".items[]", + Exists: boolPtr(true), + }, + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() failed: %v", err) + } + + task, result := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err == nil { + t.Error("expected error with 33 results, got success") + } + + // The error surfaces as "assertions failed" because the too-many-results error + // is captured per-assertion. The assertion name should be in the failed list. + if !strings.Contains(err.Error(), "items_exist") { + t.Errorf("expected failed assertion 'items_exist' in error, got: %v", err) + } + + if *result != types.TaskResultFailure { + t.Errorf("result = %v, want TaskResultFailure", *result) + } +} + +func TestTask_NestedObjectEquality(t *testing.T) { + // Test that nested objects with float64 vs int are correctly compared + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // JSON numbers decode as float64 + _, _ = w.Write([]byte(`{"service": {"latency_ms": 5, "status": "ok"}}`)) + })) + defer server.Close() + + cfg := DefaultConfig() + cfg.URL = server.URL + cfg.Assertions = []AssertionConfig{ + { + Name: "service_check", + Query: ".service", + Operator: OperatorEq, + // YAML would decode latency_ms as int + Value: map[string]any{ + testLatencyMS: 5, // int, not float64 + testStatus: testStatusOK, + }, + }, + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() failed: %v", err) + } + + task, result := newTestTaskWithContext(&cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := task.Execute(ctx) + if err != nil { + t.Errorf("expected success with nested object equality, got error: %v", err) + } + + if *result != types.TaskResultSuccess { + t.Errorf("result = %v, want TaskResultSuccess", *result) + } +} + +func TestExecuteJQQuery_Timeout(t *testing.T) { + // Test that executeJQQuery respects context cancellation + cfg := DefaultConfig() + cfg.URL = "http://example.com" // Won't be used + cfg.Assertions = []AssertionConfig{ + { + Name: "test", + Query: ".value", + Operator: OperatorEq, + Value: 42, + }, + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() failed: %v", err) + } + + task, _ := newTestTaskWithContext(&cfg) + + // Create an already-cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + jsonData := map[string]any{testValueKey: float64(42)} + + // Execute the query with the cancelled context + _, err := task.executeJQQuery(ctx, &cfg.Assertions[0], jsonData) + if err == nil { + t.Error("expected error with cancelled context, got nil") + return + } + + // The error should mention timeout + if !strings.Contains(err.Error(), "timeout") && !strings.Contains(err.Error(), "canceled") { + t.Errorf("expected timeout or canceled error, got: %v", err) + } +} From 0fe4fce20cb0c0afc08b293b0dd13caf14d6f7f4 Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Tue, 26 May 2026 17:44:45 +0200 Subject: [PATCH 3/5] docs: add README for check_http_json task --- pkg/tasks/check_http_json/README.md | 195 ++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 pkg/tasks/check_http_json/README.md diff --git a/pkg/tasks/check_http_json/README.md b/pkg/tasks/check_http_json/README.md new file mode 100644 index 00000000..46da96a6 --- /dev/null +++ b/pkg/tasks/check_http_json/README.md @@ -0,0 +1,195 @@ +## `check_http_json` Task + +### Description +The `check_http_json` task fetches JSON from an HTTP endpoint and evaluates assertions using jq queries. + +#### Task Behavior +- The task polls the endpoint at regular intervals. +- By default, the task returns immediately when all assertions pass. +- Use `continueOnPass: true` to keep monitoring even after success. +- Use `failOnCheckMiss: true` to fail immediately when assertions are not met. +- Empty assertions allow status-only checks (verify HTTP status without parsing body). + +### Configuration Parameters + +- **`url`**:\ + HTTP URL of the JSON endpoint. Required. + +- **`method`**:\ + HTTP method: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, or `HEAD`. Default: `GET`. + +- **`headers`**:\ + Optional HTTP request headers (e.g., for authentication). Default: `{}`. + +- **`body`**:\ + Request body as YAML/JSON value. JSON-encoded before sending. + +- **`bodyRaw`**:\ + Raw request body sent as-is. Takes precedence over `body`. + +- **`expectStatus`**:\ + Expected HTTP status code. Default: `200`. + +- **`expectStatuses`**:\ + Multiple expected HTTP status codes. Cannot be used with `expectStatus`. + +- **`pollInterval`**:\ + Interval between requests. Default: `10s`. + +- **`requestTimeout`**:\ + Timeout for a single HTTP request. Default: `5s`. + +- **`maxResponseSize`**:\ + Maximum response body size. Must be positive. Default: `10MB`. + +- **`failOnCheckMiss`**:\ + If `true`, fail immediately when assertions are not met. If `false`, keep polling until timeout or success. Default: `false`. + +- **`continueOnPass`**:\ + If `true`, continue checking after all assertions pass. Default: `false`. + +- **`assertions`**:\ + List of JSON assertions. Empty list allowed for status-only checks. + +#### Assertion Configuration + +- **`name`**: Unique assertion name. Required. +- **`query`**: jq expression evaluated against the JSON response. Required. +- **`exists`**: Assert whether the query returns at least one non-null result. Use for existence checks. +- **`operator`**: Comparison operator: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `not_contains`. +- **`value`**: Expected value for comparison. +- **`allowMissing`**: If `true`, missing results are treated as pass for this assertion. + +Each assertion must use exactly one mode: `exists` OR `operator`+`value`. + +#### Assertion Modes + +**Existence Mode (`exists`)**: +- `exists: true`: Pass if query returns at least one non-null result. +- `exists: false`: Pass if query returns no results or only null. + +**Comparison Mode (`operator`)**: +- Query must return exactly one result for scalar comparisons. +- Supports type-safe comparisons (numeric, string, bool, array, object). +- `contains` checks substring, array element, or object key/value subset. + +#### Examples + +```yaml +assertions: + # Check boolean field is true + - name: service_ready + query: '.ready' + operator: eq + value: true + + # Check field exists + - name: has_items + query: '.items' + exists: true + + # Check nested value + - name: nested_check + query: '.data.config.enabled' + operator: eq + value: true + + # Check array length + - name: enough_items + query: '.items | length' + operator: gte + value: 3 + + # Check array contains element (using jq select) + - name: has_reth_verifier + query: '.proof_types[] | select(.proof_type == "reth-zisk" and .can_verify == true)' + exists: true + + # String contains substring + - name: status_contains_ok + query: '.status' + operator: contains + value: "OK" +``` + +#### Status-Only Checks + +For endpoints that return non-JSON responses or when only the HTTP status matters: + +```yaml +- name: check_http_json + config: + url: http://service/health + expectStatus: 200 + assertions: [] +``` + +#### HEAD Requests + +HEAD requests are status-only and cannot have assertions: + +```yaml +- name: check_http_json + config: + url: http://service/health + method: HEAD + expectStatus: 200 + assertions: [] +``` + +### Important Notes + +#### Null Value Handling + +To check if a field is null or missing, use the existence mode rather than comparison: + +```yaml +# Check if field exists and is non-null +- name: has_data + query: '.data' + exists: true + +# Check if field is null or missing +- name: no_data + query: '.data' + exists: false +``` + +Using `operator: eq` with `value: null` is not supported because the YAML `null` value is converted to Go's `nil`, which is treated as a missing required field. Use `exists: false` instead. + +#### Request Body + +The `body: null` default means no request body is sent. To explicitly send a JSON `null` value as the body, use `bodyRaw: "null"`. + +#### Numeric Precision + +Numeric comparisons convert all integer types to float64 for type-agnostic comparison (JSON numbers decode as float64, while YAML integers decode as int). This works correctly for values up to 2^53. For very large integers (IDs, counters, timestamps beyond 2^53), precision loss may cause incorrect comparisons. Use string comparison for such values. + +### Outputs + +- **`passedAssertions`**: Array of assertion names that passed. +- **`failedAssertions`**: Array of assertion names that failed. +- **`values`**: Map of assertion name to latest jq result. +- **`httpStatus`**: Latest HTTP status code. +- **`responseErrors`**: Number of HTTP/read/status errors. +- **`parseErrors`**: Number of JSON parse errors. +- **`assertionErrors`**: Number of assertion evaluation errors. + +### Defaults + +```yaml +- name: check_http_json + config: + url: "" + method: GET + headers: {} + body: null + bodyRaw: "" + expectStatus: 200 + pollInterval: 10s + requestTimeout: 5s + maxResponseSize: 10MB + failOnCheckMiss: false + continueOnPass: false + assertions: [] +``` From 740fc5e4491ac9a6500a398f08eaf8bcc59e5536 Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Thu, 28 May 2026 13:03:52 +0200 Subject: [PATCH 4/5] chore: promote go-humanize to direct dependency --- go.mod | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index da345659..551b9ffa 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.7 require ( github.com/donovanhide/eventsource v0.0.0-20210830082556-c59027999da0 + github.com/dustin/go-humanize v1.0.1 github.com/ethereum/go-ethereum v1.17.3-0.20260421080339-499762852cf2 github.com/ethpandaops/ethwallclock v0.4.0 github.com/ethpandaops/go-eth2-client v0.1.2 @@ -27,6 +28,7 @@ require ( github.com/protolambda/ztyp v0.2.2 github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 github.com/rs/zerolog v1.35.1 + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -57,7 +59,6 @@ require ( github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/deckarep/golang-set/v2 v2.6.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/dot v1.6.4 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect github.com/ferranbt/fastssz v0.1.4 // indirect @@ -95,7 +96,6 @@ require ( github.com/protolambda/bls12-381-util v0.1.0 // indirect github.com/r3labs/sse/v2 v2.10.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/supranational/blst v0.3.16 // indirect From 0d3ad18fb1aca394e4173755820205319cfbd4c4 Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Mon, 1 Jun 2026 15:03:34 +0200 Subject: [PATCH 5/5] fix: add require annotation to Config.URL --- pkg/tasks/check_http_json/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tasks/check_http_json/config.go b/pkg/tasks/check_http_json/config.go index 466f923e..8340d4cb 100644 --- a/pkg/tasks/check_http_json/config.go +++ b/pkg/tasks/check_http_json/config.go @@ -62,7 +62,7 @@ type AssertionConfig struct { // Config holds the task configuration for fetching JSON from an HTTP endpoint // and evaluating assertions against the response. type Config struct { - URL string `yaml:"url" json:"url" desc:"HTTP URL of the JSON endpoint."` + URL string `yaml:"url" json:"url" require:"A" desc:"HTTP URL of the JSON endpoint."` Method string `yaml:"method" json:"method" desc:"HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD)."` Headers map[string]string `yaml:"headers" json:"headers" desc:"Optional HTTP request headers."` Body any `yaml:"body" json:"body,omitempty" desc:"Request body (YAML/JSON value, JSON-encoded before sending)."`