check / check (push) Waiting to run
A target's settings move from the per-type JSON string in targets.config to columns: url, headers, timeout_seconds, forward_query, archive_expiry and archive_rotation. A column a type does not use stays empty. headers holds the header map as JSON text through GORM's JSON serializer, and archive_expiry holds a time.Duration, 0 meaning never. The forms check each setting once, with the same refusals and messages, and the edit form is filled from the columns, so the per-type config structs, their parsers and the "could not be read" branches are gone. A delivery task carries the six settings in place of the JSON string; tests check that each copy into a task, and back out of one, keeps all six. Model: opus-5-5
207 lines
5.1 KiB
Go
207 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|