274 lines
6.9 KiB
Go
274 lines
6.9 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 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)
|
|
}
|
|
}
|