check / check (push) Successful in 3m26s
Adding or editing a database target now offers the archive expiry choices of the new webhook page (never, 1h, 12h, 24h, 30d, 90d, 365d) in place of a text field. The list is defined once, in internal/handlers/archive_expiry.go, and all three forms render it. The edit form starts on the expiry it shows: the stored one, or the submitted one after a refused save. One that is not among the choices is listed first as its own entry, so saving unchanged keeps it. The target list shows the expiry in plain units, such as "30 days" or "12 hours", or "never". Model: opus-5-5
407 lines
9.3 KiB
Go
407 lines
9.3 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 = "Max Retries"
|
|
)
|
|
|
|
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: "0 (fire-and-forget)",
|
|
},
|
|
fieldMap(view.Config),
|
|
)
|
|
}
|
|
|
|
// TestNewTargetViews_SlackRetries proves a Slack target shows
|
|
// its retry count the same way an HTTP target does, and no
|
|
// queue size even when one is stored: delivery never reads it.
|
|
func TestNewTargetViews_SlackRetries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
target := slackTarget()
|
|
target.MaxRetries = 2
|
|
target.MaxQueueSize = 100
|
|
|
|
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,
|
|
MaxQueueSize: 100,
|
|
})
|
|
|
|
fields := fieldMap(view.Config)
|
|
|
|
assert.Equal(
|
|
t,
|
|
map[string]string{
|
|
"Destination URL": viewMaskedOrigin,
|
|
"Timeout": "30s",
|
|
"Headers": "1 configured",
|
|
viewMaxRetries: "5",
|
|
"Max Queue Size": "100",
|
|
},
|
|
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: "0 (fire-and-forget)",
|
|
},
|
|
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},
|
|
fieldMap(view.Config),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
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"}`,
|
|
},
|
|
}
|
|
|
|
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),
|
|
)
|
|
})
|
|
}
|
|
}
|