check / check (push) Waiting to run
Target.MaxRetries (max_retries) becomes Target.MaxAttempts (max_attempts), and the form field, parser and ceiling are renamed to match. The column is NOT NULL and a check constraint refuses a value below 1, so every target, whatever its type, is stored with at least 1. 1 is a single attempt with no circuit breaker; above 1 retries behind the breaker up to that many attempts. Both target forms refuse 0 and less; a blank field on the add target form gives 1, and on the edit form keeps the stored value. The target page shows the plain number, and the README's two paragraphs on 0 against 1 become one sentence. Model: opus-5-5
455 lines
10 KiB
Go
455 lines
10 KiB
Go
package delivery_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
const (
|
|
// slackSecretPath is the credential-bearing part of a
|
|
// Slack incoming webhook URL: everything after the host.
|
|
slackSecretPath = "/services/T00000000/B00000000/" +
|
|
"XXXXXXXXXXXXXXXXXXXXXXXX"
|
|
slackWebhookURL = "https://hooks.slack.com" +
|
|
slackSecretPath
|
|
|
|
// slackMaskedURL is what a Slack webhook URL renders as
|
|
// once masked: scheme and host, path elided.
|
|
slackMaskedURL = "https://hooks.slack.com/..."
|
|
|
|
// slackTargetName is the target name the Slack projection
|
|
// tests use.
|
|
slackTargetName = "slack-target"
|
|
|
|
viewExampleOrigin = "https://example.com"
|
|
viewExampleHook = viewExampleOrigin + "/hook"
|
|
viewMaskedOrigin = viewExampleOrigin + "/..."
|
|
viewUnavailable = "(unavailable)"
|
|
viewExpiryNever = "never"
|
|
viewMaxAttempts = "Delivery attempts"
|
|
)
|
|
|
|
func TestMaskedWebhookURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := map[string]struct {
|
|
url string
|
|
want string
|
|
}{
|
|
"slack webhook": {
|
|
url: slackWebhookURL,
|
|
want: slackMaskedURL,
|
|
},
|
|
"query string dropped": {
|
|
url: viewExampleOrigin + "/a?token=secret",
|
|
want: viewExampleOrigin + "/...",
|
|
},
|
|
// Fabricated userinfo in a test URL, not a real
|
|
// credential.
|
|
//nolint:gosec // G101
|
|
"userinfo dropped": {
|
|
url: "https://user:pw@example.com/a/b",
|
|
want: viewExampleOrigin + "/...",
|
|
},
|
|
"no path": {
|
|
url: viewExampleOrigin,
|
|
want: viewExampleOrigin,
|
|
},
|
|
"root path": {
|
|
url: viewExampleOrigin + "/",
|
|
want: viewExampleOrigin,
|
|
},
|
|
"not a url": {
|
|
url: "definitely not a url",
|
|
want: viewUnavailable,
|
|
},
|
|
"empty": {
|
|
url: "",
|
|
want: viewUnavailable,
|
|
},
|
|
}
|
|
|
|
for name, tc := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &delivery.SlackTargetConfig{
|
|
WebhookURL: tc.url,
|
|
}
|
|
|
|
assert.Equal(
|
|
t, tc.want, cfg.MaskedWebhookURL(),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMaskedWebhookURL_NeverLeaksPath is the direct
|
|
// expression of the rule: whatever the input, the masked
|
|
// value never contains a path segment of it.
|
|
func TestMaskedWebhookURL_NeverLeaksPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &delivery.SlackTargetConfig{
|
|
WebhookURL: slackWebhookURL,
|
|
}
|
|
|
|
masked := cfg.MaskedWebhookURL()
|
|
|
|
assert.NotContains(t, masked, "T00000000")
|
|
assert.NotContains(t, masked, "B00000000")
|
|
assert.NotContains(
|
|
t, masked, "XXXXXXXXXXXXXXXXXXXXXXXX",
|
|
)
|
|
assert.NotContains(t, masked, slackSecretPath)
|
|
}
|
|
|
|
// fieldMap turns a view's config fields into a lookup so
|
|
// assertions read by label.
|
|
func fieldMap(fields []delivery.ConfigField) map[string]string {
|
|
out := make(map[string]string, len(fields))
|
|
for _, f := range fields {
|
|
out[f.Label] = f.Value
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// viewFor projects a single target and returns its view.
|
|
func viewFor(
|
|
t *testing.T,
|
|
target database.Target,
|
|
) delivery.TargetView {
|
|
t.Helper()
|
|
|
|
views := delivery.NewTargetViews(
|
|
[]database.Target{target},
|
|
)
|
|
require.Len(t, views, 1)
|
|
|
|
return views[0]
|
|
}
|
|
|
|
// TestNewTargetViews_DeletedTarget proves the projection marks
|
|
// a soft-deleted target's name and masks its configuration by
|
|
// the same rules a live target's is. Delivery history outlives
|
|
// the target it names, so this projection is what an operator
|
|
// reads about a target that no longer exists.
|
|
func TestNewTargetViews_DeletedTarget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
target := slackTarget()
|
|
target.DeletedAt = gorm.DeletedAt{
|
|
Time: time.Now(),
|
|
Valid: true,
|
|
}
|
|
|
|
view := viewFor(t, target)
|
|
|
|
assert.True(t, view.Deleted)
|
|
assert.Equal(t, slackTargetName, view.Name)
|
|
assert.Equal(
|
|
t, slackTargetName+" (deleted)", view.DisplayName(),
|
|
)
|
|
assert.Equal(
|
|
t, viewFor(t, slackTarget()).Config, view.Config,
|
|
)
|
|
}
|
|
|
|
// slackTarget is the live Slack target the projection tests
|
|
// share.
|
|
func slackTarget() database.Target {
|
|
return database.Target{
|
|
Name: slackTargetName,
|
|
Type: database.TargetTypeSlack,
|
|
Active: true,
|
|
Config: `{"webhookUrl":"` +
|
|
slackWebhookURL + `"}`,
|
|
MaxAttempts: 1,
|
|
}
|
|
}
|
|
|
|
func TestNewTargetViews_Slack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
view := viewFor(t, slackTarget())
|
|
|
|
assert.Equal(t, slackTargetName, view.Name)
|
|
|
|
// A live target is never marked, so the marker cannot
|
|
// reach a name that still exists.
|
|
assert.False(t, view.Deleted)
|
|
assert.Equal(t, slackTargetName, view.DisplayName())
|
|
|
|
assert.Equal(
|
|
t,
|
|
map[string]string{
|
|
"Webhook URL": slackMaskedURL,
|
|
viewMaxAttempts: "1",
|
|
},
|
|
fieldMap(view.Config),
|
|
)
|
|
}
|
|
|
|
// TestNewTargetViews_SlackAttempts proves a Slack target shows
|
|
// its number of delivery attempts the same way an HTTP target
|
|
// does.
|
|
func TestNewTargetViews_SlackAttempts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
target := slackTarget()
|
|
target.MaxAttempts = 2
|
|
|
|
view := viewFor(t, target)
|
|
|
|
assert.Equal(
|
|
t,
|
|
map[string]string{
|
|
"Webhook URL": slackMaskedURL,
|
|
viewMaxAttempts: "2",
|
|
},
|
|
fieldMap(view.Config),
|
|
)
|
|
}
|
|
|
|
func TestNewTargetViews_HTTP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
view := viewFor(t, database.Target{
|
|
Type: database.TargetTypeHTTP,
|
|
Config: `{"url":"` + viewExampleHook + `",` +
|
|
`"timeout":30,` +
|
|
`"headers":{"Authorization":"Bearer sekrit"},` +
|
|
`"forwardQuery":true}`,
|
|
MaxAttempts: 5,
|
|
})
|
|
|
|
fields := fieldMap(view.Config)
|
|
|
|
assert.Equal(
|
|
t,
|
|
map[string]string{
|
|
"Destination URL": viewMaskedOrigin,
|
|
"Timeout": "30s",
|
|
"Headers": "1 configured",
|
|
"Query string": "passed on to this target",
|
|
viewMaxAttempts: "5",
|
|
},
|
|
fields,
|
|
)
|
|
|
|
// Header values can be credentials and are never shown.
|
|
for _, v := range fields {
|
|
assert.NotContains(t, v, "sekrit")
|
|
}
|
|
}
|
|
|
|
// TestNewTargetViews_HTTPSingleAttempt proves a single attempt is
|
|
// shown as the plain number, like any other.
|
|
func TestNewTargetViews_HTTPSingleAttempt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
view := viewFor(t, database.Target{
|
|
Type: database.TargetTypeHTTP,
|
|
Config: `{"url":"` + viewExampleHook + `"}`,
|
|
MaxAttempts: 1,
|
|
})
|
|
|
|
assert.Equal(
|
|
t,
|
|
map[string]string{
|
|
"Destination URL": viewMaskedOrigin,
|
|
viewMaxAttempts: "1",
|
|
},
|
|
fieldMap(view.Config),
|
|
)
|
|
}
|
|
|
|
// TestNewTargetViews_HTTPMasksDestinationURL proves the rule
|
|
// holds for the http target too: an http destination is
|
|
// routinely an incoming-webhook endpoint whose path segments
|
|
// are the credential, so none of them is shown.
|
|
func TestNewTargetViews_HTTPMasksDestinationURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
view := viewFor(t, database.Target{
|
|
Type: database.TargetTypeHTTP,
|
|
Config: `{"url":"` + slackWebhookURL + `"}`,
|
|
MaxAttempts: 1,
|
|
})
|
|
|
|
fields := fieldMap(view.Config)
|
|
|
|
assert.Equal(
|
|
t,
|
|
slackMaskedURL,
|
|
fields["Destination URL"],
|
|
)
|
|
|
|
for _, v := range fields {
|
|
assert.NotContains(t, v, slackSecretPath)
|
|
assert.NotContains(t, v, "T00000000")
|
|
assert.NotContains(t, v, "B00000000")
|
|
assert.NotContains(t, v, "XXXXXXXXXXXXXXXXXXXXXXXX")
|
|
}
|
|
}
|
|
|
|
func TestNewTargetViews_Database(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := map[string]struct {
|
|
config string
|
|
want string
|
|
}{
|
|
"empty config": {config: "", want: viewExpiryNever},
|
|
"empty expiry": {config: `{}`, want: viewExpiryNever},
|
|
"never literal": {
|
|
config: `{"expiry":"` + viewExpiryNever + `"}`,
|
|
want: viewExpiryNever,
|
|
},
|
|
"1h": {config: `{"expiry":"1h"}`, want: "1 hour"},
|
|
"12h": {config: `{"expiry":"12h"}`, want: "12 hours"},
|
|
"24h": {config: `{"expiry":"24h"}`, want: "1 day"},
|
|
"720h": {config: `{"expiry":"720h"}`, want: "30 days"},
|
|
"2160h": {config: `{"expiry":"2160h"}`, want: "90 days"},
|
|
"8760h": {config: `{"expiry":"8760h"}`, want: "365 days"},
|
|
"36h": {config: `{"expiry":"36h"}`, want: "36 hours"},
|
|
"1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"},
|
|
"45s": {config: `{"expiry":"45s"}`, want: "45 seconds"},
|
|
"1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"},
|
|
}
|
|
|
|
for name, tc := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
view := viewFor(t, database.Target{
|
|
Type: database.TargetTypeDatabase,
|
|
Config: tc.config,
|
|
})
|
|
|
|
assert.Equal(
|
|
t,
|
|
map[string]string{
|
|
"Archive expiry": tc.want,
|
|
"Archive rotation": rotationNone,
|
|
},
|
|
fieldMap(view.Config),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNewTargetViews_DatabaseRotation proves the target list shows a
|
|
// database target's rotation, none when it has none stored.
|
|
func TestNewTargetViews_DatabaseRotation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Each stored config, and the rotation the list shows for it.
|
|
tests := map[string]string{
|
|
"": rotationNone,
|
|
`{"rotation":""}`: rotationNone,
|
|
}
|
|
|
|
for _, rotation := range []string{
|
|
rotationNone, rotationMonthly, rotationDaily, rotationHourly,
|
|
} {
|
|
tests[`{"rotation":"`+rotation+`"}`] = rotation
|
|
}
|
|
|
|
for config, want := range tests {
|
|
t.Run(config, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
view := viewFor(t, database.Target{
|
|
Type: database.TargetTypeDatabase,
|
|
Config: config,
|
|
})
|
|
|
|
assert.Equal(
|
|
t, want, fieldMap(view.Config)["Archive rotation"],
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewTargetViews_Log(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
view := viewFor(t, database.Target{
|
|
Type: database.TargetTypeLog,
|
|
Config: "",
|
|
})
|
|
|
|
assert.Empty(t, view.Config)
|
|
}
|
|
|
|
// TestNewTargetViews_Unpresentable proves that no config the
|
|
// view cannot present falls back to the stored blob.
|
|
func TestNewTargetViews_Unpresentable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const blob = `{"webhookUrl":"https://hooks.slack.com` +
|
|
slackSecretPath + `"`
|
|
|
|
tests := map[string]database.Target{
|
|
"unknown target type": {
|
|
Type: database.TargetType("carrier-pigeon"),
|
|
Config: blob,
|
|
},
|
|
"unparseable json": {
|
|
Type: database.TargetTypeSlack,
|
|
Config: blob,
|
|
MaxAttempts: 1,
|
|
},
|
|
"empty slack config": {
|
|
Type: database.TargetTypeSlack,
|
|
MaxAttempts: 1,
|
|
},
|
|
"slack config without url": {
|
|
Type: database.TargetTypeSlack,
|
|
Config: `{}`,
|
|
MaxAttempts: 1,
|
|
},
|
|
"unparseable http json": {
|
|
Type: database.TargetTypeHTTP,
|
|
Config: `{"url":`,
|
|
MaxAttempts: 1,
|
|
},
|
|
"unparseable archive json": {
|
|
Type: database.TargetTypeDatabase,
|
|
Config: `{"expiry":`,
|
|
},
|
|
"invalid archive expiry": {
|
|
Type: database.TargetTypeDatabase,
|
|
Config: `{"expiry":"a fortnight"}`,
|
|
},
|
|
"invalid archive rotation": {
|
|
Type: database.TargetTypeDatabase,
|
|
Config: weeklyConfig,
|
|
},
|
|
}
|
|
|
|
for name, target := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
view := viewFor(t, target)
|
|
|
|
assert.Equal(
|
|
t,
|
|
map[string]string{
|
|
"Configuration": viewUnavailable,
|
|
},
|
|
fieldMap(view.Config),
|
|
)
|
|
})
|
|
}
|
|
}
|