package delivery_test import ( "strconv" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/delivery" ) // Literals these tests repeat, named so that each header name has one // definition. const ( headerAuthorization = "Authorization" headerContentType = "Content-Type" headerUserAgent = "User-Agent" bearerValue = "Bearer abc" ) func TestParseTargetHeaders_AcceptsPairs(t *testing.T) { t.Parallel() got, err := delivery.ParseTargetHeaders( " Authorization: Bearer abc \n\n" + "x-tenant:acme\r\n" + "X-Empty:\n", ) require.NoError(t, err) assert.Equal( t, map[string]string{ headerAuthorization: bearerValue, "X-Tenant": "acme", "X-Empty": "", }, got, ) } // An input of blank lines is no headers, not an error. func TestParseTargetHeaders_EmptyInputYieldsNoHeaders(t *testing.T) { t.Parallel() got, err := delivery.ParseTargetHeaders("\n \n") require.NoError(t, err) assert.Empty(t, got) } func TestParseTargetHeaders_Rejects(t *testing.T) { t.Parallel() cases := map[string]string{ "no colon": "Authorization Bearer abc", "empty name": ": value", "space in name": "X Bad: value", "reserved host": "Host: evil.example", "reserved ua": "User-Agent: curl/8", "reserved length": "Content-Length: 0", "duplicate any case": "X-A: 1\nx-a: 2", } for name, input := range cases { t.Run(name, func(t *testing.T) { t.Parallel() _, err := delivery.ParseTargetHeaders(input) require.Error(t, err) }) } } // net/http strips Trailer from the request it writes, so accepting // one would store a header that never reaches the target. func TestParseTargetHeaders_RejectsTrailer(t *testing.T) { t.Parallel() _, err := delivery.ParseTargetHeaders("Trailer: X-Checksum") require.Error(t, err) assert.Contains(t, err.Error(), "Trailer") } // A header value is routinely a bearer token and these errors are // rendered into a 400 body, so no message may quote one. func TestParseTargetHeaders_ErrorsNeverQuoteAValue(t *testing.T) { t.Parallel() const secret = "QQNEVERINAMESSAGEQQ" inputs := []string{ // The value, after the colon, in a duplicate name. "X-A: " + secret + "\nx-a: " + secret, // The value after the colon of an unusable name. "X Bad Name: " + secret, // The line splits on the value's own colon, so the // secret lands in the text an unusable-name error is // tempted to quote as the name. "X-Api-Key " + secret + ":x", // The same, with nothing before the secret at all. secret + " and more:x", // A control character in the value. "X-A: " + secret + "\x01", } for _, input := range inputs { _, err := delivery.ParseTargetHeaders(input) require.Error(t, err, input) assert.NotContains(t, err.Error(), secret, input) } } // Loading the edit form twice without saving must not reshuffle // the textarea, which Go's map iteration order would otherwise do. func TestFormatTargetHeaders_IsSorted(t *testing.T) { t.Parallel() got := delivery.FormatTargetHeaders(map[string]string{ "X-Zed": "z", headerAuthorization: bearerValue, "X-Alpha": "a", }) assert.Equal( t, "Authorization: Bearer abc\nX-Alpha: a\nX-Zed: z\n", got, ) assert.Empty(t, delivery.FormatTargetHeaders(nil)) } func TestFormatTargetHeaders_RoundTripsThroughParse(t *testing.T) { t.Parallel() want := map[string]string{ headerAuthorization: bearerValue, "X-Tenant": "acme", } got, err := delivery.ParseTargetHeaders( delivery.FormatTargetHeaders(want), ) require.NoError(t, err) assert.Equal(t, want, got) } func TestParseTargetTimeout(t *testing.T) { t.Parallel() got, err := delivery.ParseTargetTimeout(" 30 ") require.NoError(t, err) assert.Equal(t, 30, got) got, err = delivery.ParseTargetTimeout("") require.NoError(t, err) assert.Zero(t, got) for _, bad := range []string{"soon", "-1", "1e3", "100000"} { _, err = delivery.ParseTargetTimeout(bad) require.Error(t, err, bad) } } func TestFormatTargetTimeout(t *testing.T) { t.Parallel() assert.Equal(t, "30", delivery.FormatTargetTimeout(30)) assert.Empty(t, delivery.FormatTargetTimeout(0)) assert.Empty(t, delivery.FormatTargetTimeout(-1)) } // The ceiling exists so one misconfigured target cannot hold a // delivery worker indefinitely, and it is inclusive. func TestParseTargetTimeout_CeilingIsInclusive(t *testing.T) { t.Parallel() assert.Positive(t, delivery.MaxTargetTimeoutSeconds) got, err := delivery.ParseTargetTimeout( strconv.Itoa(delivery.MaxTargetTimeoutSeconds), ) require.NoError(t, err) assert.Equal(t, delivery.MaxTargetTimeoutSeconds, got) _, err = delivery.ParseTargetTimeout( strconv.Itoa(delivery.MaxTargetTimeoutSeconds + 1), ) require.Error(t, err) } // Control characters in a value are how a second header would be // smuggled into the outbound request. func TestParseTargetHeaders_RejectsControlCharactersInValues( t *testing.T, ) { t.Parallel() for _, bad := range []string{ "X-A: one\x01two", "X-A: one\ttwo", "X-A: one\x7ftwo", } { _, err := delivery.ParseTargetHeaders(bad) require.Error(t, err, bad) } }