sourcegraph/internal/database/outbound_webhooks_test.go
Erik Seliger d4cb67425b
Speed up some slow DB tests (#59131)
Found this package frustratingly slow, so looked at the worst offenders.

Top 5 before:

```
TestOutboundWebhookJobs,3.61
TestRecentViewSignalStore_InsertPaths_OverBatchSize,4.9
TestFeatureFlagStore,6.28
TestEventLogs_OwnershipFeatureActivity,7.05
TestExternalServicesStore_Upsert,9.72
```

Top 5 after:

```
TestPermsStore_GrantPendingPermissions,2.28
TestExternalServicesStore_DeleteExtServiceWithManyRepos,2.43
TestAccessTokens,2.47
TestWebhookUpdate,2.67
TestFeatureFlagStore,4.95
```
2023-12-20 17:42:23 +00:00

454 lines
14 KiB
Go

package database
import (
"context"
"fmt"
"strconv"
"testing"
"time"
"github.com/sourcegraph/log"
"github.com/sourcegraph/log/logtest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sourcegraph/sourcegraph/internal/database/basestore"
"github.com/sourcegraph/sourcegraph/internal/database/dbtest"
"github.com/sourcegraph/sourcegraph/internal/database/dbutil"
"github.com/sourcegraph/sourcegraph/internal/encryption"
et "github.com/sourcegraph/sourcegraph/internal/encryption/testing"
"github.com/sourcegraph/sourcegraph/internal/errcode"
"github.com/sourcegraph/sourcegraph/internal/types"
"github.com/sourcegraph/sourcegraph/lib/pointers"
)
func TestOutboundWebhooks(t *testing.T) {
t.Parallel()
ctx := context.Background()
runBothEncryptionStates(t, func(t *testing.T, logger log.Logger, db DB, key encryption.Key) {
user, err := db.Users().Create(ctx, NewUser{
Username: strconv.Itoa(int(time.Now().UnixMilli())),
})
require.NoError(t, err)
store := db.OutboundWebhooks(key)
var createdWebhook *types.OutboundWebhook
t.Run("Create", func(t *testing.T) {
t.Run("no event types", func(t *testing.T) {
webhook := newTestWebhook(t, user)
err := store.Create(ctx, webhook)
assert.ErrorIs(t, err, errOutboundWebhookHasNoEventTypes)
})
t.Run("encryption failure", func(t *testing.T) {
store := db.OutboundWebhooks(&et.BadKey{})
webhook := newTestWebhook(t, user)
err := store.Create(ctx, webhook)
assert.Error(t, err)
})
t.Run("success", func(t *testing.T) {
createdWebhook = newTestWebhook(
t, user,
ScopedEventType{EventType: "foo"},
ScopedEventType{EventType: "bar"},
ScopedEventType{EventType: "quux", Scope: pointers.Ptr("123")},
)
err := store.Create(ctx, createdWebhook)
assert.NoError(t, err)
assert.NotZero(t, createdWebhook.ID)
assert.NotZero(t, createdWebhook.CreatedAt)
assert.NotZero(t, createdWebhook.UpdatedAt)
for _, eventType := range createdWebhook.EventTypes {
assert.NotZero(t, eventType.ID)
assert.Equal(t, createdWebhook.ID, eventType.OutboundWebhookID)
}
assertOutboundWebhookFieldsEncrypted(t, ctx, store, createdWebhook)
})
})
t.Run("GetByID", func(t *testing.T) {
t.Run("not found", func(t *testing.T) {
webhook, err := store.GetByID(ctx, 0)
assert.True(t, errcode.IsNotFound(err))
assert.Nil(t, webhook)
})
t.Run("found", func(t *testing.T) {
webhook, err := store.GetByID(ctx, createdWebhook.ID)
assert.NoError(t, err)
assertEqualWebhooks(t, ctx, createdWebhook, webhook)
})
})
t.Run("List/Count", func(t *testing.T) {
// OK, let's create a few more webhooks now for testing
// purposes.
newSavedTestWebhook := func(t *testing.T, user *types.User, scopes ...ScopedEventType) *types.OutboundWebhook {
t.Helper()
webhook := newTestWebhook(t, user, scopes...)
require.NoError(t, store.Create(ctx, webhook))
return webhook
}
fooOnlyWebhook := newSavedTestWebhook(t, user, ScopedEventType{EventType: "foo"})
barOnlyWebhook := newSavedTestWebhook(t, user, ScopedEventType{EventType: "bar"})
quuxWithSameScopeWebhook := newSavedTestWebhook(
t, user,
ScopedEventType{EventType: "quux", Scope: pointers.Ptr("123")},
)
quuxWithDifferentScopeWebhook := newSavedTestWebhook(
t, user,
ScopedEventType{EventType: "quux", Scope: pointers.Ptr("456")},
)
allWebhooks := []*types.OutboundWebhook{
createdWebhook,
fooOnlyWebhook,
barOnlyWebhook,
quuxWithSameScopeWebhook,
quuxWithDifferentScopeWebhook,
}
t.Run("unpaginated", func(t *testing.T) {
for name, tc := range map[string]struct {
opts OutboundWebhookListOpts
want []*types.OutboundWebhook
}{
"no matches based on event type": {
opts: OutboundWebhookListOpts{
OutboundWebhookCountOpts: OutboundWebhookCountOpts{
EventTypes: []FilterEventType{{EventType: "not found"}},
},
},
want: []*types.OutboundWebhook{},
},
"scoped, missing type": {
opts: OutboundWebhookListOpts{
OutboundWebhookCountOpts: OutboundWebhookCountOpts{
EventTypes: []FilterEventType{
{EventType: "not found", Scope: pointers.Ptr(FilterEventTypeNoScope)},
},
},
},
want: []*types.OutboundWebhook{},
},
"scoped, no scopes in type": {
opts: OutboundWebhookListOpts{
OutboundWebhookCountOpts: OutboundWebhookCountOpts{
EventTypes: []FilterEventType{
{EventType: "foo", Scope: pointers.Ptr("bar")},
},
},
},
want: []*types.OutboundWebhook{},
},
"scoped, missing scope in type": {
opts: OutboundWebhookListOpts{
OutboundWebhookCountOpts: OutboundWebhookCountOpts{
EventTypes: []FilterEventType{
{EventType: "quux", Scope: pointers.Ptr("789")},
},
},
},
want: []*types.OutboundWebhook{},
},
"all": {
opts: OutboundWebhookListOpts{},
want: allWebhooks,
},
"unscoped": {
opts: OutboundWebhookListOpts{
OutboundWebhookCountOpts: OutboundWebhookCountOpts{
EventTypes: []FilterEventType{{EventType: "foo"}, {EventType: "bar"}},
},
},
want: []*types.OutboundWebhook{
createdWebhook, fooOnlyWebhook, barOnlyWebhook,
},
},
"scoped with null scopes": {
// This should return the foos, but no quuxs, since
// they have scopes attached.
opts: OutboundWebhookListOpts{
OutboundWebhookCountOpts: OutboundWebhookCountOpts{
EventTypes: []FilterEventType{
{EventType: "foo", Scope: pointers.Ptr(FilterEventTypeNoScope)},
{EventType: "quux", Scope: pointers.Ptr(FilterEventTypeNoScope)},
},
},
},
want: []*types.OutboundWebhook{
createdWebhook, fooOnlyWebhook,
},
},
"scoped with non-null scopes": {
// This should return the quuxs, but no foos, since
// the foos don't have scopes.
opts: OutboundWebhookListOpts{
OutboundWebhookCountOpts: OutboundWebhookCountOpts{
EventTypes: []FilterEventType{
{EventType: "foo", Scope: pointers.Ptr("no match")},
{EventType: "quux", Scope: pointers.Ptr("123")},
{EventType: "quux", Scope: pointers.Ptr("456")},
{EventType: "quux", Scope: pointers.Ptr("789")},
},
},
},
want: []*types.OutboundWebhook{
createdWebhook,
quuxWithSameScopeWebhook,
quuxWithDifferentScopeWebhook,
},
},
"scoped with only one scope": {
opts: OutboundWebhookListOpts{
OutboundWebhookCountOpts: OutboundWebhookCountOpts{
EventTypes: []FilterEventType{
{EventType: "quux", Scope: pointers.Ptr("123")},
},
},
},
want: []*types.OutboundWebhook{
createdWebhook,
quuxWithSameScopeWebhook,
},
},
"mixed unscoped and scoped": {
opts: OutboundWebhookListOpts{
OutboundWebhookCountOpts: OutboundWebhookCountOpts{
EventTypes: []FilterEventType{{EventType: "bar"},
{EventType: "quux", Scope: pointers.Ptr("123")},
},
},
},
want: []*types.OutboundWebhook{
createdWebhook,
barOnlyWebhook,
quuxWithSameScopeWebhook,
},
},
} {
t.Run(name, func(t *testing.T) {
have, err := store.List(ctx, tc.opts)
assert.NoError(t, err)
assertEqualWebhookSlices(t, ctx, tc.want, have)
count, err := store.Count(ctx, tc.opts.OutboundWebhookCountOpts)
assert.NoError(t, err)
assert.EqualValues(t, len(tc.want), count)
})
}
})
t.Run("pagination", func(t *testing.T) {
// We won't rehash all the unpaginated tests above, since
// that was really exercising the filtering; instead, we'll
// just ensure all results are paginated as we expect.
for i, want := range [][]*types.OutboundWebhook{
{createdWebhook, fooOnlyWebhook},
{barOnlyWebhook, quuxWithSameScopeWebhook},
{quuxWithDifferentScopeWebhook},
{},
} {
t.Run(fmt.Sprintf("page %d", i+1), func(t *testing.T) {
have, err := store.List(ctx, OutboundWebhookListOpts{
LimitOffset: &LimitOffset{
Offset: i * 2,
Limit: 2,
},
})
assert.NoError(t, err)
assertEqualWebhookSlices(t, ctx, want, have)
})
}
})
})
t.Run("Update", func(t *testing.T) {
t.Run("fail due to missing event types", func(t *testing.T) {
createdWebhook.EventTypes = []types.OutboundWebhookEventType{}
err := store.Update(ctx, createdWebhook)
assert.ErrorIs(t, err, errOutboundWebhookHasNoEventTypes)
})
t.Run("replace all event types", func(t *testing.T) {
createdWebhook.EventTypes = []types.OutboundWebhookEventType{
{EventType: "new"},
}
err := store.Update(ctx, createdWebhook)
assert.NoError(t, err)
have, err := store.GetByID(ctx, createdWebhook.ID)
require.NoError(t, err)
assertEqualEventTypes(t, have.ID, createdWebhook.EventTypes, have.EventTypes)
})
t.Run("append to the current event types", func(t *testing.T) {
createdWebhook.EventTypes = append(
createdWebhook.EventTypes,
types.OutboundWebhookEventType{EventType: "newer", Scope: pointers.Ptr("abc")},
)
err := store.Update(ctx, createdWebhook)
assert.NoError(t, err)
have, err := store.GetByID(ctx, createdWebhook.ID)
require.NoError(t, err)
assertEqualEventTypes(t, have.ID, createdWebhook.EventTypes, have.EventTypes)
})
t.Run("update other fields", func(t *testing.T) {
createdWebhook.URL.Set("https://a.new.value")
createdWebhook.Secret.Set("a whole new secret")
err := store.Update(ctx, createdWebhook)
assert.NoError(t, err)
have, err := store.GetByID(ctx, createdWebhook.ID)
require.NoError(t, err)
assertEqualWebhooks(t, ctx, createdWebhook, have)
assertOutboundWebhookFieldsEncrypted(t, ctx, store, have)
})
})
t.Run("Delete", func(t *testing.T) {
err := store.Delete(ctx, createdWebhook.ID)
assert.NoError(t, err)
_, err = store.GetByID(ctx, createdWebhook.ID)
assert.True(t, errcode.IsNotFound(err))
})
})
}
func assertOutboundWebhookFieldsEncrypted(t *testing.T, ctx context.Context, store basestore.ShareableStore, webhook *types.OutboundWebhook) {
t.Helper()
if store.(*outboundWebhookStore).key == nil {
return
}
url, err := webhook.URL.Decrypt(ctx)
require.NoError(t, err)
secret, err := webhook.Secret.Decrypt(ctx)
require.NoError(t, err)
row := store.Handle().QueryRowContext(
ctx,
"SELECT url, secret, encryption_key_id FROM outbound_webhooks WHERE id = $1",
webhook.ID,
)
var (
dbURL string
dbSecret string
keyID string
)
err = row.Scan(&dbURL, &dbSecret, &dbutil.NullString{S: &keyID})
assert.NoError(t, err)
assert.NotEmpty(t, keyID)
assert.NotEqual(t, dbURL, url)
assert.NotEqual(t, dbSecret, secret)
}
func assertEqualEventTypes(t *testing.T, webhookID int64, want, have []types.OutboundWebhookEventType) {
t.Helper()
type unidentifiedEventType struct {
outboundWebhookID int64
eventType string
scope *string
}
comparableEventTypes := func(eventTypes []types.OutboundWebhookEventType) []unidentifiedEventType {
t.Helper()
comp := make([]unidentifiedEventType, len(eventTypes))
for i, eventType := range eventTypes {
assert.Equal(t, webhookID, eventType.OutboundWebhookID)
comp[i] = unidentifiedEventType{
outboundWebhookID: eventType.OutboundWebhookID,
eventType: eventType.EventType,
scope: eventType.Scope,
}
}
return comp
}
assert.ElementsMatch(t, comparableEventTypes(want), comparableEventTypes(have))
}
func assertEqualWebhooks(t *testing.T, ctx context.Context, want, have *types.OutboundWebhook) {
t.Helper()
valueOf := func(e *encryption.Encryptable) string {
t.Helper()
return decryptedValue(t, ctx, e)
}
// We need this helper because the encryptable values need to be decrypted
// before it makes sense to compare them, and because event type IDs are (in
// practice) ephemeral, so we only really care about the actual values.
assert.Equal(t, want.ID, have.ID)
assert.Equal(t, want.CreatedBy, have.CreatedBy)
assert.Equal(t, want.CreatedAt, have.CreatedAt)
assert.Equal(t, want.UpdatedBy, have.UpdatedBy)
assert.Equal(t, want.UpdatedAt, have.UpdatedAt)
assert.Equal(t, valueOf(want.URL), valueOf(have.URL))
assert.Equal(t, valueOf(want.Secret), valueOf(have.Secret))
assertEqualEventTypes(t, want.ID, want.EventTypes, have.EventTypes)
}
func assertEqualWebhookSlices(t *testing.T, ctx context.Context, want, have []*types.OutboundWebhook) {
assert.Equal(t, len(want), len(have))
for i := range want {
assertEqualWebhooks(t, ctx, want[i], have[i])
}
}
func decryptedValue(t *testing.T, ctx context.Context, e *encryption.Encryptable) string {
t.Helper()
value, err := e.Decrypt(ctx)
require.NoError(t, err)
return value
}
func newTestWebhook(t *testing.T, user *types.User, scopes ...ScopedEventType) *types.OutboundWebhook {
t.Helper()
webhook := &types.OutboundWebhook{
CreatedBy: user.ID,
UpdatedBy: user.ID,
URL: encryption.NewUnencrypted("https://example.com/"),
Secret: encryption.NewUnencrypted("super secret"),
EventTypes: make([]types.OutboundWebhookEventType, 0, len(scopes)),
}
for _, scope := range scopes {
webhook.EventTypes = append(webhook.EventTypes, webhook.NewEventType(scope.EventType, scope.Scope))
}
return webhook
}
func runBothEncryptionStates(t *testing.T, f func(t *testing.T, logger log.Logger, db DB, key encryption.Key)) {
t.Helper()
logger := logtest.Scoped(t)
db := NewDB(logger, dbtest.NewDB(t))
t.Run("unencrypted", func(t *testing.T) {
t.Cleanup(func() {
_, err := db.ExecContext(context.Background(), "DELETE FROM outbound_webhooks CASCADE")
require.NoError(t, err)
})
f(t, logger, db, nil)
})
t.Run("encrypted", func(t *testing.T) { f(t, logger, db, et.ByteaTestKey{}) })
}