All checks were successful
check / check (push) Successful in 3m8s
Deleting a target soft deletes its row while its deliveries survive in the per-webhook database, so the event log kept rendering the history and lost the label: every historical row read ": delivered" and the deliveries block was headed by an empty name. loadTargetMap already loaded soft-deleted rows Unscoped for the redactor half, then discarded them before building the view half. It now builds both halves from every loaded row, and TargetView carries a Deleted flag with a DisplayName that renders "name (deleted)" — an operator debugging an old delivery needs to know the target is gone, not just what it was called. The views still come from NewTargetViews, so a deleted target's configuration is masked by exactly the code that masks a live one's. The widening is confined to this map, which feeds only DeliveryView.Target on the event log page: the source detail target list, the target edit form, the receiver and resubmit fan-out and the delivery engine each resolve targets through their own scoped queries, and the replay path keeps refusing a deleted target.
377 lines
8.3 KiB
Go
377 lines
8.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"
|
|
)
|
|
|
|
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,
|
|
map[string]string{"Webhook URL": slackMaskedURL},
|
|
fieldMap(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},
|
|
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",
|
|
"Max Retries": "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,
|
|
"Max Retries": "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},
|
|
"explicit": {
|
|
config: `{"expiry":"720h"}`,
|
|
want: "720h",
|
|
},
|
|
"never literal": {
|
|
config: `{"expiry":"` + viewExpiryNever + `"}`,
|
|
want: viewExpiryNever,
|
|
},
|
|
}
|
|
|
|
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),
|
|
)
|
|
})
|
|
}
|
|
}
|