package appsecacquisition

import (
	"io"
	"net/http"
	"net/url"
	"strings"
	"testing"

	"github.com/davecgh/go-spew/spew"
	log "github.com/sirupsen/logrus"
	"github.com/stretchr/testify/require"

	"github.com/crowdsecurity/crowdsec/pkg/appsec"
	"github.com/crowdsecurity/crowdsec/pkg/appsec/appsec_rule"
	"github.com/crowdsecurity/crowdsec/pkg/pipeline"
)

// jsonBody returns an io.ReadCloser that reads back `body` as many times as
// the validator needs it, so we can reuse the same test request across phases.
func jsonBody(body string) io.ReadCloser {
	return io.NopCloser(strings.NewReader(body))
}

func TestAppsecOnMatchHooks(t *testing.T) {
	tests := []appsecRuleTest{
		{
			name:             "no rule : check return code",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 2)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Equal(t, pipeline.LOG, events[1].Type)
				require.Len(t, responses, 1)
				require.Equal(t, 403, responses[0].BouncerHTTPResponseCode)
				require.Equal(t, 403, responses[0].UserHTTPResponseCode)
				require.Equal(t, appsec.BanRemediation, responses[0].Action)
			},
		},
		{
			name:             "on_match: change return code",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"SetReturnCode(413)"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 2)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Equal(t, pipeline.LOG, events[1].Type)
				require.Len(t, responses, 1)
				require.Equal(t, 403, responses[0].BouncerHTTPResponseCode)
				require.Equal(t, 413, responses[0].UserHTTPResponseCode)
				require.Equal(t, appsec.BanRemediation, responses[0].Action)
			},
		},
		{
			name:             "on_match: change action to a non standard one (log)",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('log')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 2)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Equal(t, pipeline.LOG, events[1].Type)
				require.Len(t, responses, 1)
				require.Equal(t, "log", responses[0].Action)
				require.Equal(t, 403, responses[0].BouncerHTTPResponseCode)
				require.Equal(t, 403, responses[0].UserHTTPResponseCode)
			},
		},
		{
			name:             "on_match: change action to another standard one (allow)",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('allow')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 2)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Equal(t, pipeline.LOG, events[1].Type)
				require.Len(t, responses, 1)
				require.Equal(t, appsec.AllowRemediation, responses[0].Action)
			},
		},
		{
			name:             "on_match: change action to another standard one (ban)",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('ban')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, responses, 1)
				//note: SetAction normalizes deny, ban and block to ban
				require.Equal(t, appsec.BanRemediation, responses[0].Action)
			},
		},
		{
			name:             "on_match: change action to another standard one (captcha)",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('captcha')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, responses, 1)
				//note: SetAction normalizes deny, ban and block to ban
				require.Equal(t, appsec.CaptchaRemediation, responses[0].Action)
			},
		},
		{
			name:             "on_match: change action to a non standard one",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('foobar')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 2)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Equal(t, pipeline.LOG, events[1].Type)
				require.Len(t, responses, 1)
				require.Equal(t, "foobar", responses[0].Action)
			},
		},
		{
			name:             "on_match: cancel alert",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule42",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true && LogInfo('XX -> %s', evt.Appsec.MatchedRules.GetName())", Apply: []string{"CancelAlert()"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 1)
				require.Equal(t, pipeline.LOG, events[0].Type)
				require.Len(t, responses, 1)
				require.Equal(t, appsec.BanRemediation, responses[0].Action)
			},
		},
		{
			name:             "on_match: cancel event",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule42",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"CancelEvent()"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 1)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Len(t, responses, 1)
				require.Equal(t, appsec.BanRemediation, responses[0].Action)
			},
		},
		{
			name:             "on_match: on_success break",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule42",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"CancelEvent()"}, OnSuccess: "break"},
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('captcha')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 1)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Len(t, responses, 1)
				require.Equal(t, appsec.BanRemediation, responses[0].Action)
			},
		},
		{
			name:             "on_match: on_success continue",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule42",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"CancelEvent()"}, OnSuccess: "continue"},
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('captcha')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 1)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Len(t, responses, 1)
				require.Equal(t, appsec.CaptchaRemediation, responses[0].Action)
			},
		},
		{
			name:             "on_match: SendAlert() with out-of-band rule",
			expected_load_ok: true,
			outofband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule42",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			DefaultRemediation: appsec.AllowRemediation,
			on_match: []appsec.Hook{
				{Filter: "IsInBand == false", Apply: []string{"SendAlert()"}},
			},
			input_request: appsec.ParsedRequest{
				ClientIP:    "1.2.3.4",
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Equal(t, appsec.AllowRemediation, appsecResponse.Action)
				require.Equal(t, http.StatusOK, appsecResponse.HTTPStatus)
				require.Equal(t, http.StatusOK, statusCode)
				// We have both an event an overflow
				require.Len(t, events, 2)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Equal(t, pipeline.LOG, events[1].Type)
				require.Nil(t, events[1].Overflow.Alert)
				require.NotNil(t, events[0].Overflow.Alert)
			},
		},
		{
			name:             "on_match: no alert with default config",
			expected_load_ok: true,
			outofband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule42",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 1)
				require.Equal(t, pipeline.LOG, events[0].Type)
				require.Len(t, responses, 1)
				require.Equal(t, appsec.AllowRemediation, responses[0].Action)
			},
		},
	}

	runTests(t, tests)
}

func TestAppsecPreEvalHooks(t *testing.T) {
	tests := []appsecRuleTest{
		{
			name:             "Basic pre_eval hook to disable inband rule",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			pre_eval: []appsec.Hook{
				{Filter: "1 == 1", Apply: []string{"RemoveInBandRuleByName('rule1')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Empty(t, events)
				require.Len(t, responses, 1)
				require.False(t, responses[0].InBandInterrupt)
				require.False(t, responses[0].OutOfBandInterrupt)
			},
		},
		{
			name:             "Basic pre_eval fails to disable rule",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			pre_eval: []appsec.Hook{
				{Filter: "1 ==2", Apply: []string{"RemoveInBandRuleByName('rule1')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 2)
				require.Equal(t, pipeline.APPSEC, events[0].Type)

				require.Equal(t, pipeline.LOG, events[1].Type)
				require.True(t, events[1].Appsec.HasInBandMatches)
				require.Len(t, events[1].Appsec.MatchedRules, 1)
				require.Equal(t, "test-rule", events[1].Appsec.MatchedRules[0]["msg"])

				require.Len(t, responses, 1)
				require.True(t, responses[0].InBandInterrupt)
			},
		},
		{
			name:             "pre_eval : disable inband by tag",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rulez",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			pre_eval: []appsec.Hook{
				{Apply: []string{"RemoveInBandRuleByTag('crowdsec-rulez')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Empty(t, events)
				require.Len(t, responses, 1)
				require.False(t, responses[0].InBandInterrupt)
				require.False(t, responses[0].OutOfBandInterrupt)
			},
		},
		{
			name:             "pre_eval : disable inband by ID",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rulez",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			pre_eval: []appsec.Hook{
				{Apply: []string{"RemoveInBandRuleByID(1516470898)"}}, //rule ID is generated at runtime. If you change rule, it will break the test (:
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Empty(t, events)
				require.Len(t, responses, 1)
				require.False(t, responses[0].InBandInterrupt)
				require.False(t, responses[0].OutOfBandInterrupt)
			},
		},
		{
			name:             "pre_eval : disable inband by name",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rulez",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			pre_eval: []appsec.Hook{
				{Apply: []string{"RemoveInBandRuleByName('rulez')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Empty(t, events)
				require.Len(t, responses, 1)
				require.False(t, responses[0].InBandInterrupt)
				require.False(t, responses[0].OutOfBandInterrupt)
			},
		},
		{
			name:             "pre_eval : outofband default behavior",
			expected_load_ok: true,
			outofband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rulez",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 1)
				require.Equal(t, pipeline.LOG, events[0].Type)
				require.True(t, events[0].Appsec.HasOutBandMatches)
				require.False(t, events[0].Appsec.HasInBandMatches)
				require.Len(t, events[0].Appsec.MatchedRules, 1)
				require.Equal(t, "test-rule", events[0].Appsec.MatchedRules[0]["msg"])
				//maybe surprising, but response won't mention OOB event, as it's sent as soon as the inband phase is over.
				require.Len(t, responses, 1)
				require.False(t, responses[0].InBandInterrupt)
				require.False(t, responses[0].OutOfBandInterrupt)
			},
		},
		{
			name:             "pre_eval : set remediation by tag",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rulez",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			pre_eval: []appsec.Hook{
				{Apply: []string{"SetRemediationByTag('crowdsec-rulez', 'foobar')"}}, //rule ID is generated at runtime. If you change rule, it will break the test (:
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 2)
				require.Len(t, responses, 1)
				require.Equal(t, "foobar", responses[0].Action)
			},
		},
		{
			name:             "pre_eval : set remediation by name",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rulez",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			pre_eval: []appsec.Hook{
				{Apply: []string{"SetRemediationByName('rulez', 'foobar')"}}, //rule ID is generated at runtime. If you change rule, it will break the test (:
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 2)
				require.Len(t, responses, 1)
				require.Equal(t, "foobar", responses[0].Action)
			},
		},
		{
			name:             "pre_eval : drop request helper",
			expected_load_ok: true,
			pre_eval: []appsec.Hook{
				{Apply: []string{"DropRequest('drop requested by config')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 3)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Equal(t, pipeline.LOG, events[1].Type)
				require.Equal(t, pipeline.LOG, events[2].Type)
				require.True(t, events[1].Appsec.HasInBandMatches)
				require.True(t, events[2].Appsec.HasOutBandMatches)
				require.Len(t, responses, 1)
				require.True(t, responses[0].InBandInterrupt)
				require.Equal(t, appsec.BanRemediation, responses[0].Action)
				require.Equal(t, 403, responses[0].UserHTTPResponseCode)
				require.Equal(t, 403, responses[0].BouncerHTTPResponseCode)
				require.Len(t, events[1].Appsec.MatchedRules, 1)
				require.Equal(t, "drop requested by config", events[1].Appsec.MatchedRules[0]["name"])
				require.Equal(t, "drop requested by config", events[1].Appsec.MatchedRules[0]["msg"])
				require.Equal(t, "drop requested by config", events[1].Parsed["appsec_drop_reason"])
			},
		},
		{
			name:             "pre_eval : set remediation and return code",
			expected_load_ok: true,
			pre_eval: []appsec.Hook{
				{Apply: []string{"SetRemediation('log')", "SetReturnCode(418)"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Empty(t, events)
				require.Len(t, responses, 1)
				require.Equal(t, appsec.AllowRemediation, responses[0].Action)
				require.Equal(t, 200, responses[0].UserHTTPResponseCode)
			},
		},
		{
			name:             "pre_eval : drop helper with remediation override",
			expected_load_ok: true,
			pre_eval: []appsec.Hook{
				{Apply: []string{"SetRemediation('log')", "SetReturnCode(418)", "DropRequest('drop requested by config')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 3)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Equal(t, pipeline.LOG, events[1].Type)
				require.Equal(t, pipeline.LOG, events[2].Type)
				require.True(t, events[1].Appsec.HasInBandMatches)
				require.True(t, events[2].Appsec.HasOutBandMatches)
				require.Len(t, responses, 1)
				require.True(t, responses[0].InBandInterrupt)
				require.Equal(t, "log", responses[0].Action)
				require.Equal(t, 418, responses[0].UserHTTPResponseCode)
				require.Equal(t, "drop requested by config", events[1].Appsec.MatchedRules[0]["name"])
				require.Equal(t, "drop requested by config", events[2].Appsec.MatchedRules[0]["name"])
			},
		},
		{
			name:             "pre_eval : set remediation by ID",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rulez",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			pre_eval: []appsec.Hook{
				{Apply: []string{"SetRemediationByID(1516470898, 'foobar')"}}, //rule ID is generated at runtime. If you change rule, it will break the test (:
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 2)
				require.Len(t, responses, 1)
				require.Equal(t, "foobar", responses[0].Action)
				require.Equal(t, "foobar", appsecResponse.Action)
				require.Equal(t, http.StatusForbidden, appsecResponse.HTTPStatus)
			},
		},
		{
			name:             "pre_eval : on_success continue",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rulez",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			pre_eval: []appsec.Hook{
				{Filter: "1==1", Apply: []string{"SetRemediationByName('rulez', 'foobar')"}, OnSuccess: "continue"},
				{Filter: "1==1", Apply: []string{"SetRemediationByName('rulez', 'foobar2')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 2)
				require.Len(t, responses, 1)
				require.Equal(t, "foobar2", responses[0].Action)
			},
		},
		{
			name:             "pre_eval : on_success break",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rulez",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			pre_eval: []appsec.Hook{
				{Filter: "1==1", Apply: []string{"SetRemediationByName('rulez', 'foobar')"}, OnSuccess: "break"},
				{Filter: "1==1", Apply: []string{"SetRemediationByName('rulez', 'foobar2')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 2)
				require.Len(t, responses, 1)
				require.Equal(t, "foobar", responses[0].Action)
			},
		},
	}

	runTests(t, tests)
}

func TestAppsecRemediationConfigHooks(t *testing.T) {
	tests := []appsecRuleTest{
		{
			name:             "Basic matching rule",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Equal(t, appsec.BanRemediation, responses[0].Action)
				require.Equal(t, http.StatusForbidden, statusCode)
				require.Equal(t, appsec.BanRemediation, appsecResponse.Action)
				require.Equal(t, http.StatusForbidden, appsecResponse.HTTPStatus)
			},
		},
		{
			name:             "SetRemediation",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			on_match: []appsec.Hook{{Apply: []string{"SetRemediation('captcha')"}}}, //rule ID is generated at runtime. If you change rule, it will break the test (:

			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Equal(t, appsec.CaptchaRemediation, responses[0].Action)
				require.Equal(t, http.StatusForbidden, statusCode)
				require.Equal(t, appsec.CaptchaRemediation, appsecResponse.Action)
				require.Equal(t, http.StatusForbidden, appsecResponse.HTTPStatus)
			},
		},
		{
			name:             "SetRemediation",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			on_match: []appsec.Hook{{Apply: []string{"SetReturnCode(418)"}}}, //rule ID is generated at runtime. If you change rule, it will break the test (:

			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Equal(t, appsec.BanRemediation, responses[0].Action)
				require.Equal(t, http.StatusForbidden, statusCode)
				require.Equal(t, appsec.BanRemediation, appsecResponse.Action)
				require.Equal(t, http.StatusTeapot, appsecResponse.HTTPStatus)
			},
		},
	}

	runTests(t, tests)
}

func TestOnMatchRemediationHooks(t *testing.T) {
	tests := []appsecRuleTest{
		{
			name:             "set remediation to allow with on_match hook",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule42",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('allow')"}},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Equal(t, appsec.AllowRemediation, appsecResponse.Action)
				require.Equal(t, http.StatusOK, appsecResponse.HTTPStatus)
			},
		},
		{
			name:             "set remediation to captcha + custom user code with on_match hook",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule42",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			DefaultRemediation: appsec.AllowRemediation,
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('captcha')", "SetReturnCode(418)"}},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				spew.Dump(responses)
				spew.Dump(appsecResponse)

				log.Errorf("http status : %d", statusCode)
				require.Equal(t, appsec.CaptchaRemediation, appsecResponse.Action)
				require.Equal(t, http.StatusTeapot, appsecResponse.HTTPStatus)
				require.Equal(t, http.StatusForbidden, statusCode)
			},
		},
		{
			name:             "on_match: on_success break",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule42",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			DefaultRemediation: appsec.AllowRemediation,
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('captcha')", "SetReturnCode(418)"}, OnSuccess: "break"},
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('ban')"}},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				spew.Dump(responses)
				spew.Dump(appsecResponse)

				log.Errorf("http status : %d", statusCode)
				require.Equal(t, appsec.CaptchaRemediation, appsecResponse.Action)
				require.Equal(t, http.StatusTeapot, appsecResponse.HTTPStatus)
				require.Equal(t, http.StatusForbidden, statusCode)
			},
		},
		{
			name:             "on_match: on_success continue",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule42",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			DefaultRemediation: appsec.AllowRemediation,
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('captcha')", "SetReturnCode(418)"}, OnSuccess: "continue"},
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('ban')"}},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				spew.Dump(responses)
				spew.Dump(appsecResponse)

				log.Errorf("http status : %d", statusCode)
				require.Equal(t, appsec.BanRemediation, appsecResponse.Action)
				require.Equal(t, http.StatusTeapot, appsecResponse.HTTPStatus)
				require.Equal(t, http.StatusForbidden, statusCode)
			},
		},
		{
			name:             "on_match: allowlisted IP",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule42",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			input_request: appsec.ParsedRequest{
				ClientIP:    "5.4.3.2",
				RemoteAddr:  "5.4.3.2",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			DefaultRemediation: appsec.AllowRemediation,
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('captcha')", "SetReturnCode(418)"}, OnSuccess: "continue"},
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('ban')"}},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				spew.Dump(responses)
				spew.Dump(appsecResponse)

				log.Errorf("http status : %d", statusCode)
				require.Equal(t, appsec.AllowRemediation, appsecResponse.Action)
				require.Equal(t, http.StatusOK, appsecResponse.HTTPStatus)
				require.Equal(t, http.StatusOK, statusCode)
			},
		},
	}

	runTests(t, tests)
}

func TestAppsecPhaseScopedHooks(t *testing.T) {
	tests := []appsecRuleTest{
		{
			name:             "inband on_match: change return code (phase-scoped, no IsInBand filter needed)",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			inband_on_match: []appsec.Hook{
				{Apply: []string{"SetReturnCode(413)"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, events, 2)
				require.Len(t, responses, 1)
				require.Equal(t, 413, responses[0].UserHTTPResponseCode)
			},
		},
		{
			name:             "inband on_match: set remediation (phase-scoped)",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			inband_on_match: []appsec.Hook{
				{Apply: []string{"SetRemediation('captcha')"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, responses, 1)
				require.Equal(t, "captcha", responses[0].Action)
			},
		},
		{
			name:             "inband on_match: cancel event (phase-scoped)",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			inband_on_match: []appsec.Hook{
				{Apply: []string{"CancelEvent()"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				// CancelEvent() only cancels the LOG event, the APPSEC alert is still sent
				require.Len(t, events, 1)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Len(t, responses, 1)
			},
		},
		{
			name:             "inband on_match with filter: only fires when filter matches (phase-scoped)",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			inband_on_match: []appsec.Hook{
				{Filter: "evt.Appsec.HasInBandMatches == true", Apply: []string{"SetReturnCode(418)"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, responses, 1)
				require.Equal(t, 418, responses[0].UserHTTPResponseCode)
			},
		},
		{
			name:             "shared + inband on_match: both execute (shared runs first)",
			expected_load_ok: true,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{
				// Shared hook: runs for both phases, sets remediation
				{Filter: "IsInBand == true", Apply: []string{"SetRemediation('captcha')"}},
			},
			inband_on_match: []appsec.Hook{
				// Phase-scoped hook: overrides the return code
				{Apply: []string{"SetReturnCode(418)"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, responses, 1)
				// Shared hook set captcha, phase-scoped hook set return code 418
				require.Equal(t, "captcha", responses[0].Action)
				require.Equal(t, 418, responses[0].UserHTTPResponseCode)
			},
		},
		{
			name:               "shared on_match break does not prevent phase-scoped hooks",
			expected_load_ok:   true,
			DefaultRemediation: appsec.AllowRemediation,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			on_match: []appsec.Hook{
				{Filter: "IsInBand == true", Apply: []string{"CancelEvent()"}, OnSuccess: "break"},
			},
			inband_on_match: []appsec.Hook{
				{Apply: []string{"SetRemediation('captcha')", "SetReturnCode(418)"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, responses, 1)
				// Shared hook canceled LOG event with break, APPSEC alert still sent
				// Phase-scoped hooks still run (break only stops shared hook list)
				require.Len(t, events, 1)
				require.Equal(t, pipeline.APPSEC, events[0].Type)
				require.Equal(t, "captcha", responses[0].Action)
				require.Equal(t, 418, responses[0].UserHTTPResponseCode)
			},
		},
		{
			name:               "outofband on_match: send alert (phase-scoped)",
			expected_load_ok:   true,
			DefaultRemediation: appsec.AllowRemediation,
			outofband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			outofband_on_match: []appsec.Hook{
				{Apply: []string{"SendAlert()"}},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, responses, 1)
				require.Equal(t, appsec.AllowRemediation, appsecResponse.Action)
				// outofband matched and sent an alert event
				require.NotEmpty(t, events)
				foundAppsecEvt := false
				for _, evt := range events {
					if evt.Type == pipeline.APPSEC {
						foundAppsecEvt = true
					}
				}
				require.True(t, foundAppsecEvt, "expected an APPSEC event from outofband match")
			},
		},
		{
			name:               "inband on_match with break+continue: break stops inband hooks",
			expected_load_ok:   true,
			DefaultRemediation: appsec.AllowRemediation,
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:      "rule1",
					Zones:     []string{"ARGS"},
					Variables: []string{"foo"},
					Match:     appsec_rule.Match{Type: "regex", Value: "^toto"},
					Transform: []string{"lowercase"},
				},
			},
			inband_on_match: []appsec.Hook{
				// break requires a filter to be present and match (sets has_match flag)
				{Filter: "evt.Appsec.HasInBandMatches == true", Apply: []string{"SetRemediation('captcha')", "SetReturnCode(418)"}, OnSuccess: "break"},
				{Filter: "evt.Appsec.HasInBandMatches == true", Apply: []string{"SetRemediation('ban')"}}, // should not execute due to break
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr:  "1.2.3.4",
				Method:      "GET",
				URI:         "/urllll",
				Args:        url.Values{"foo": []string{"toto"}},
				HTTPRequest: &http.Request{Host: "example.com"},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, responses, 1)
				// First hook ran (captcha + 418), second was skipped due to break
				require.Equal(t, "captcha", responses[0].Action)
				require.Equal(t, 418, responses[0].UserHTTPResponseCode)
			},
		},
	}

	runTests(t, tests)
}

// minimal schema used by the hook_vars-in-event tests: /users only accepts
// a POST whose body has a required username field.
const testUsersSchema = `openapi: 3.0.0
info:
  title: users
  version: "1"
paths:
  /users:
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username]
              additionalProperties: false
              properties:
                username: {type: string}
      responses:
        "200": {description: ok}
`

func TestAppsecHookVarsSurfacedInEvent(t *testing.T) {
	tests := []appsecRuleTest{
		{
			name:             "validation failure publishes hook_vars to event and matches",
			expected_load_ok: true,
			schemas:          map[string]string{"users_api": testUsersSchema},
			inband_pre_eval: []appsec.Hook{
				{
					Filter: "!ValidateRequestWithSchema('users_api')",
					Apply: []string{
						"DropRequest('schema validation failed on ' + hook_vars.validation_error_field)",
					},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr: "1.2.3.4",
				Method:     http.MethodPost,
				URI:        "/users",
				Body:       []byte(`{}`),
				Headers:    http.Header{"Content-Type": []string{"application/json"}},
				HTTPRequest: &http.Request{
					Host:   "example.com",
					Method: http.MethodPost,
					URL:    mustParseURL("http://example.com/users"),
					Body:   jsonBody(`{}`),
					Header: http.Header{"Content-Type": []string{"application/json"}},
				},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, responses, 1)
				require.True(t, responses[0].InBandInterrupt)

				// Locate the APPSEC overflow (alert) and the in-band LOG event.
				var overflow, logEvt *pipeline.Event
				for i := range events {
					e := &events[i]
					switch {
					case e.Type == pipeline.APPSEC:
						overflow = e
					case e.Type == pipeline.LOG && e.Appsec.HasInBandMatches:
						logEvt = e
					}
				}
				require.NotNil(t, overflow, "expected an APPSEC overflow event")
				require.NotNil(t, logEvt, "expected an in-band LOG event")

				// Overflow carries HookVars (propagated by AppsecEventGeneration).
				require.Equal(t, "request_body", overflow.Appsec.HookVars["validation_error_reason"])
				require.Equal(t, "username", overflow.Appsec.HookVars["validation_error_field"])

				// LOG event has HookVars, matched-rules list, and drop_reason.
				require.Equal(t, "request_body", logEvt.Appsec.HookVars["validation_error_reason"])
				require.Equal(t, "username", logEvt.Appsec.HookVars["validation_error_field"])
				require.NotEmpty(t, logEvt.Appsec.HookVars["validation_error_message"])
				require.NotEmpty(t, logEvt.Appsec.HookVars["validation_error"])
				require.Contains(t, logEvt.Meta["appsec_drop_reason"], "schema validation failed")

				require.NotEmpty(t, logEvt.Appsec.MatchedRules)
				for i, match := range logEvt.Appsec.MatchedRules {
					hv, ok := match["hook_vars"].(map[string]string)
					require.True(t, ok, "match %d missing hook_vars", i)
					require.Equal(t, "request_body", hv["validation_error_reason"])
				}
			},
		},
		{
			name:             "successful validation leaves hook_vars empty on the event",
			expected_load_ok: true,
			schemas:          map[string]string{"users_api": testUsersSchema},
			inband_rules: []appsec_rule.CustomRule{
				{
					Name:  "rule-always-fires",
					Zones: []string{"METHOD"},
					Match: appsec_rule.Match{Type: "equals", Value: "POST"},
				},
			},
			inband_pre_eval: []appsec.Hook{
				{
					Filter: "!ValidateRequestWithSchema('users_api')",
					Apply:  []string{"DropRequest('schema failed')"},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr: "1.2.3.4",
				Method:     http.MethodPost,
				URI:        "/users",
				Body:       []byte(`{"username":"jane"}`),
				Headers:    http.Header{"Content-Type": []string{"application/json"}},
				HTTPRequest: &http.Request{
					Host:   "example.com",
					Method: http.MethodPost,
					URL:    mustParseURL("http://example.com/users"),
					Body:   jsonBody(`{"username":"jane"}`),
					Header: http.Header{"Content-Type": []string{"application/json"}},
				},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.NotEmpty(t, events)
				for _, evt := range events {
					require.Empty(t, evt.Appsec.HookVars["validation_error"],
						"successful validation must not leave validation_error on the event")
				}
			},
		},
		{
			// Regression: ValidateRequestWithSchema must be safe to call from
			// both in-band and out-of-band pre_eval on the same request.
			// We use the failure path because the validator publishes
			// validation_error_* keys to hook_vars only on failure — the
			// per-phase appsec_drop_reason on each event is direct evidence
			// that each phase's validation call ran against the body.
			name:             "validation runs in both in-band and out-of-band on same request",
			expected_load_ok: true,
			schemas:          map[string]string{"users_api": testUsersSchema},
			inband_pre_eval: []appsec.Hook{
				{
					Filter: "!ValidateRequestWithSchema('users_api')",
					Apply:  []string{"DropRequest('inband: ' + hook_vars.validation_error_reason)"},
				},
			},
			outofband_pre_eval: []appsec.Hook{
				{
					Filter: "!ValidateRequestWithSchema('users_api')",
					Apply:  []string{"DropRequest('outofband: ' + hook_vars.validation_error_reason)"},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr: "1.2.3.4",
				Method:     http.MethodPost,
				URI:        "/users",
				Body:       []byte(`{}`), // missing required `username`
				Headers:    http.Header{"Content-Type": []string{"application/json"}},
				HTTPRequest: &http.Request{
					Host:   "example.com",
					Method: http.MethodPost,
					URL:    mustParseURL("http://example.com/users"),
					Body:   jsonBody(`{}`),
					Header: http.Header{"Content-Type": []string{"application/json"}},
				},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				var inbandLog, outbandLog *pipeline.Event
				for i := range events {
					e := &events[i]
					if e.Type != pipeline.LOG {
						continue
					}
					switch {
					case e.Appsec.HasInBandMatches:
						inbandLog = e
					case e.Appsec.HasOutBandMatches:
						outbandLog = e
					}
				}
				require.NotNil(t, inbandLog, "expected an in-band LOG event")
				require.NotNil(t, outbandLog, "expected an out-of-band LOG event")

				// Each phase ran the validator and produced its own failure
				// signal — proves both calls reached the body. (HookVars is
				// shared per-request, so the keys would be present on the
				// out-of-band event regardless; the drop_reason is the
				// per-phase, per-call evidence.)
				require.Equal(t, "request_body", inbandLog.Appsec.HookVars["validation_error_reason"])
				require.Contains(t, inbandLog.Meta["appsec_drop_reason"], "inband: request_body")
				require.Equal(t, "request_body", outbandLog.Appsec.HookVars["validation_error_reason"])
				require.Contains(t, outbandLog.Parsed["appsec_drop_reason"], "outofband: request_body")
			},
		},
		{
			// A truncated body is partial bytes — for JSON, that means an
			// incomplete document. The validator has no special-case for
			// BodyTruncated; it parses what it gets and surfaces the parse
			// error as a normal request_body validation failure. This is the
			// fail-closed behavior we want: an attacker oversizing the body
			// to slip past schema validation should not pass.
			name:             "validation fails on a truncated JSON body",
			expected_load_ok: true,
			schemas:          map[string]string{"users_api": testUsersSchema},
			inband_pre_eval: []appsec.Hook{
				{
					Filter: "!ValidateRequestWithSchema('users_api')",
					Apply:  []string{"DropRequest('schema validation failed: ' + hook_vars.validation_error_message)"},
				},
			},
			input_request: appsec.ParsedRequest{
				RemoteAddr: "1.2.3.4",
				Method:     http.MethodPost,
				URI:        "/users",
				// Partial JSON — the closing brace and value were cut off
				// past the truncation point.
				Body:          []byte(`{"username":"jan`),
				BodyTruncated: true,
				Headers:       http.Header{"Content-Type": []string{"application/json"}},
				HTTPRequest: &http.Request{
					Host:   "example.com",
					Method: http.MethodPost,
					URL:    mustParseURL("http://example.com/users"),
					Body:   jsonBody(`{"username":"jan`),
					Header: http.Header{"Content-Type": []string{"application/json"}},
				},
			},
			output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
				require.Len(t, responses, 1)
				require.True(t, responses[0].InBandInterrupt, "truncated body must fail validation and drop")

				var inbandLog *pipeline.Event
				for i := range events {
					if events[i].Type == pipeline.LOG && events[i].Appsec.HasInBandMatches {
						inbandLog = &events[i]
						break
					}
				}
				require.NotNil(t, inbandLog)
				require.Equal(t, "request_body", inbandLog.Appsec.HookVars["validation_error_reason"])
				require.NotEmpty(t, inbandLog.Appsec.HookVars["validation_error_message"],
					"validator must surface the underlying JSON parse error")
				require.Contains(t, inbandLog.Meta["appsec_drop_reason"], "schema validation failed:")
			},
		},
	}

	runTests(t, tests)
}

func mustParseURL(raw string) *url.URL {
	u, err := url.Parse(raw)
	if err != nil {
		panic(err)
	}
	return u
}
