check / check (push) Waiting to run
The UI gave one thing several names. The `database` type is now Archive in the type list, on its badge and on the edit page, and its settings read "Archive expiry" and "Archive rotation" everywhere. The retry field is "Delivery attempts", with help text, errors and the target list saying it counts every attempt; a stored 0 shows as one attempt. The target list uses one capitalisation. The navbar says "Sign out", the sign-in page "Sign in", and the resubmit notice "webhook" instead of "source". The README follows. Stored values, their meaning, routes and form field names are unchanged. Model: opus-5-5
443 lines
10 KiB
Go
443 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"
|
|
viewMaxRetries = "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 + `"}`,
|
|
}
|
|
}
|
|
|
|
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,
|
|
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
|
},
|
|
fieldMap(view.Config),
|
|
)
|
|
}
|
|
|
|
// TestNewTargetViews_SlackRetries proves a Slack target shows
|
|
// its retry count the same way an HTTP target does.
|
|
func TestNewTargetViews_SlackRetries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
target := slackTarget()
|
|
target.MaxRetries = 2
|
|
|
|
view := viewFor(t, target)
|
|
|
|
assert.Equal(
|
|
t,
|
|
map[string]string{
|
|
"Webhook URL": slackMaskedURL,
|
|
viewMaxRetries: "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"}}`,
|
|
MaxRetries: 5,
|
|
})
|
|
|
|
fields := fieldMap(view.Config)
|
|
|
|
assert.Equal(
|
|
t,
|
|
map[string]string{
|
|
"Destination URL": viewMaskedOrigin,
|
|
"Timeout": "30s",
|
|
"Headers": "1 configured",
|
|
viewMaxRetries: "5",
|
|
},
|
|
fields,
|
|
)
|
|
|
|
// Header values can be credentials and are never shown.
|
|
for _, v := range fields {
|
|
assert.NotContains(t, v, "sekrit")
|
|
}
|
|
}
|
|
|
|
func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
view := viewFor(t, database.Target{
|
|
Type: database.TargetTypeHTTP,
|
|
Config: `{"url":"` + viewExampleHook + `"}`,
|
|
})
|
|
|
|
assert.Equal(
|
|
t,
|
|
map[string]string{
|
|
"Destination URL": viewMaskedOrigin,
|
|
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
|
},
|
|
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 + `"}`,
|
|
})
|
|
|
|
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,
|
|
},
|
|
"empty slack config": {
|
|
Type: database.TargetTypeSlack,
|
|
},
|
|
"slack config without url": {
|
|
Type: database.TargetTypeSlack,
|
|
Config: `{}`,
|
|
},
|
|
"unparseable http json": {
|
|
Type: database.TargetTypeHTTP,
|
|
Config: `{"url":`,
|
|
},
|
|
"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),
|
|
)
|
|
})
|
|
}
|
|
}
|