Files
webhooker/internal/delivery/target_headers_test.go
T
clawbot 82bab57dfe
check / check (push) Waiting to run
Store a target's settings as columns (closes #543)
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, so nothing that reads a target parses
it. 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.

Model: opus-5-5
2026-10-15 19:54:42 +00:00

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)
}
}