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