check / check (push) Waiting to run
A database target's archive expiry was typed by hand as never or a raw duration such as 720h, and the target list showed it back raw. Adding or editing a database target now offers the new-webhook page's list of choices (never, 1h, 12h, 24h, 30d, 90d, 365d), defined once and shared by all three forms. The edit form starts on the stored expiry, or on the submitted one after a refused save; a stored value outside the choices is listed under its own value, so saving unchanged keeps it. The target list shows the expiry in plain units: "30 days", "12 hours", "never". Model: opus-5-5
295 lines
7.7 KiB
Go
295 lines
7.7 KiB
Go
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 names and the
|
|
// keep-forever archive config each have one definition.
|
|
const (
|
|
headerAuthorization = "Authorization"
|
|
headerContentType = "Content-Type"
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
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 yields an empty expiry, so the edit
|
|
// form starts on never; saving it unchanged stores never, which means
|
|
// the same as an empty expiry.
|
|
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)
|
|
}
|
|
}
|