Add a target edit form with headers and timeout fields (closes #127) (#229)
Some checks failed
check / check (push) Failing after 2m59s

This commit was merged in pull request #229.
This commit is contained in:
2026-08-20 07:24:12 +02:00
parent c6a9884f86
commit aba02bc509
11 changed files with 1764 additions and 44 deletions

View File

@@ -0,0 +1,273 @@
package delivery_test
import (
"encoding/json"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// Literals these tests repeat, named so that the header name and the
// keep-forever archive config each have one definition.
const (
headerAuthorization = "Authorization"
bearerValue = "Bearer abc"
archiveConfigNever = "{\"expiry\":\"never\"}"
)
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,
)
}
// A configuration with no headers must stay indistinguishable from
// one written before the field existed, so omitempty drops the key.
func TestParseTargetHeaders_EmptyInputYieldsNoHeaders(t *testing.T) {
t.Parallel()
got, err := delivery.ParseTargetHeaders("\n \n")
require.NoError(t, err)
assert.Empty(t, got)
encoded, err := json.Marshal(delivery.HTTPTargetConfig{
URL: "https://example.com/h",
Headers: got,
})
require.NoError(t, err)
assert.JSONEq(
t, `{"url":"https://example.com/h"}`, string(encoded),
)
}
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)
})
}
}
// 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"
_, err := delivery.ParseTargetHeaders(
"X-A: " + secret + "\nx-a: " + secret,
)
require.Error(t, err)
assert.NotContains(t, err.Error(), secret)
_, err = delivery.ParseTargetHeaders(
"X Bad Name: " + secret,
)
require.Error(t, err)
assert.NotContains(t, err.Error(), secret)
}
// 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))
}
func TestNewTargetConfigForm(t *testing.T) {
t.Parallel()
form, err := delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeHTTP,
Config: `{"url":"https://example.com/h",` +
`"headers":{"Authorization":"Bearer abc"},` +
`"timeout":9}`,
})
require.NoError(t, err)
assert.Equal(t, "https://example.com/h", form.URL)
assert.Equal(t, "Authorization: Bearer abc\n", form.Headers)
assert.Equal(t, "9", form.Timeout)
form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeSlack,
Config: `{"webhookUrl":"https://hooks.example/s"}`,
})
require.NoError(t, err)
assert.Equal(t, "https://hooks.example/s", form.URL)
form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeDatabase,
Config: `{"expiry":"720h"}`,
})
require.NoError(t, err)
assert.Equal(t, "720h", form.Expiry)
form, err = delivery.NewTargetConfigForm(&database.Target{
Type: database.TargetTypeLog,
})
require.NoError(t, err)
assert.Empty(t, form.URL)
}
// A keep-forever archive target must pre-fill as an empty field, so
// saving the form back unchanged stores the same empty config.
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
t.Parallel()
for _, cfg := range []string{"", `{}`, archiveConfigNever} {
form, err := delivery.NewTargetConfigForm(
&database.Target{
Type: database.TargetTypeDatabase,
Config: cfg,
},
)
require.NoError(t, err, cfg)
assert.Empty(t, form.Expiry, cfg)
}
}
// An unreadable stored config is an error rather than a blank form
// that looks like a target with no settings, so the caller can tell
// the operator that saving replaces the stored value.
func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) {
t.Parallel()
cases := []*database.Target{
{Type: database.TargetTypeHTTP, Config: "not json"},
{Type: database.TargetTypeHTTP, Config: `{}`},
{Type: database.TargetTypeSlack, Config: ""},
{
Type: database.TargetTypeDatabase,
Config: `{"expiry":"soon"}`,
},
{Type: database.TargetType("nope")},
}
for _, target := range cases {
_, err := delivery.NewTargetConfigForm(target)
require.Error(t, err, target.Type)
}
}
// 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)
}
}