Files
webhooker/internal/delivery/target_headers_test.go
T
clawbot 8b617efa63
check / check (push) Waiting to run
Rotate a database target's archive monthly, daily or hourly (closes #379)
A database target's rotation setting (none, monthly, daily or hourly) puts the UTC period of each event's receive time in its archive file name, so each file holds exactly its period's events. It is on the new webhook page and both target forms, and shown in the target list. Renames move every one of a target's files and move them all back if one fails. The sweep prunes one file at a time under the target's lock and deletes a rotated file it leaves empty. Download opens one file at a time, oldest period first, finding each again under the target's current name. The target list names the current file and totals all of them.

Model: opus-5-5
2026-10-03 04:18:37 +02:00

300 lines
7.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 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","rotation":"daily"}`,
})
require.NoError(t, err)
assert.Equal(t, "720h", form.Expiry)
assert.Equal(t, rotationDaily, form.Rotation)
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.TargetTypeDatabase,
Config: weeklyConfig,
},
{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)
}
}