All checks were successful
check / check (push) Successful in 3m26s
A Delivery carries its Event and Target structs in memory for the delivery engine, so GORM's automatic association save upserted the whole target row -- config included, which holds destination URLs and bearer credentials -- into the per-webhook event database with an empty webhook_id. Event databases are the files most likely to be backed up or handed to someone else, so they shipped the credentials with them. Register a create and update callback on every per-webhook connection that omits associations, rather than fixing the one call site: it covers writes inside a transaction and write paths added later. Sweep any rows already written, before the migration on each open, so it is idempotent and a no-op on a database with no targets table. Encryption of target config at rest in webhooker.db is deliberately not part of this: it is tracked separately.
291 lines
7.4 KiB
Go
291 lines
7.4 KiB
Go
package database_test
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
_ "modernc.org/sqlite"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// testDataDirPerm is the mode the test data directory is created
|
|
// with.
|
|
const testDataDirPerm = 0o750
|
|
|
|
// eventDBDataDir returns a data directory that a WebhookDBManager
|
|
// can be pointed at.
|
|
func eventDBDataDir(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir := filepath.Join(t.TempDir(), "events")
|
|
require.NoError(t, os.MkdirAll(dir, testDataDirPerm))
|
|
|
|
return dir
|
|
}
|
|
|
|
// openRawEventDB opens the per-webhook database file directly,
|
|
// without the manager, so a test can put a file on disk in a state
|
|
// the manager has to cope with, or inspect one afterwards.
|
|
func openRawEventDB(
|
|
t *testing.T, dataDir, webhookID string,
|
|
) *sql.DB {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(
|
|
dataDir, fmt.Sprintf("events-%s.db", webhookID),
|
|
)
|
|
|
|
sqlDB, err := sql.Open(
|
|
"sqlite",
|
|
fmt.Sprintf("file:%s?mode=rwc", path),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
|
|
return sqlDB
|
|
}
|
|
|
|
// eventDBFileBytes reads a per-webhook database file off disk, so a
|
|
// test can assert on what the file itself still holds rather than on
|
|
// what a query returns.
|
|
func eventDBFileBytes(t *testing.T, dataDir, webhookID string) []byte {
|
|
t.Helper()
|
|
|
|
//nolint:gosec // reads a file the test just created under t.TempDir()
|
|
raw, err := os.ReadFile(filepath.Join(
|
|
dataDir, fmt.Sprintf("events-%s.db", webhookID),
|
|
))
|
|
require.NoError(t, err)
|
|
|
|
return raw
|
|
}
|
|
|
|
// countTargetRows returns the number of rows in the targets table of
|
|
// a per-webhook database file, or -1 if the table does not exist.
|
|
func countTargetRows(t *testing.T, sqlDB *sql.DB) int {
|
|
t.Helper()
|
|
|
|
var tables int
|
|
|
|
require.NoError(t, sqlDB.QueryRowContext(
|
|
t.Context(),
|
|
"SELECT count(*) FROM sqlite_master "+
|
|
"WHERE type = 'table' AND name = 'targets'",
|
|
).Scan(&tables))
|
|
|
|
if tables == 0 {
|
|
return -1
|
|
}
|
|
|
|
var rows int
|
|
|
|
require.NoError(t, sqlDB.QueryRowContext(
|
|
t.Context(), "SELECT count(*) FROM targets",
|
|
).Scan(&rows))
|
|
|
|
return rows
|
|
}
|
|
|
|
// TestOpenPurgesLeakedTargetRows covers the sweep for event
|
|
// databases written by a build that let GORM upsert target rows
|
|
// into them: opening the database clears them, and opening it again
|
|
// is a no-op.
|
|
func TestOpenPurgesLeakedTargetRows(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := eventDBDataDir(t)
|
|
webhookID := uuid.New().String()
|
|
|
|
// Create the file the way the application does, so the targets
|
|
// table has exactly the shape AutoMigrate gives it, then write
|
|
// a leaked row into it the way the association upsert did.
|
|
initial := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err := initial.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, initial.CloseAll())
|
|
|
|
seed := openRawEventDB(t, dataDir, webhookID)
|
|
|
|
_, err = seed.ExecContext(
|
|
t.Context(),
|
|
"INSERT INTO targets "+
|
|
"(id, webhook_id, name, type, config) "+
|
|
"VALUES (?, '', ?, ?, ?)",
|
|
uuid.New().String(),
|
|
"leaked-target",
|
|
"slack",
|
|
`{"webhookUrl":"https://hooks.example/T000/B000/secret"}`,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, countTargetRows(t, seed))
|
|
require.NoError(t, seed.Close())
|
|
|
|
mgr := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err = mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, mgr.CloseAll())
|
|
|
|
check := openRawEventDB(t, dataDir, webhookID)
|
|
assert.Zero(t, countTargetRows(t, check))
|
|
require.NoError(t, check.Close())
|
|
|
|
// Idempotent: a second open leaves it at zero and does not
|
|
// error.
|
|
again := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err = again.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, again.CloseAll())
|
|
|
|
recheck := openRawEventDB(t, dataDir, webhookID)
|
|
assert.Zero(t, countTargetRows(t, recheck))
|
|
}
|
|
|
|
// TestOpenPurgeRemovesCredentialBytes covers the sweep at the level
|
|
// that matters for a backup handed to someone else: the leaked
|
|
// credential must be gone from the raw bytes of the file, not merely
|
|
// unreachable by query. A bare DELETE unlinks the row and leaves the
|
|
// bytes readable in the free pages, so this fails without the VACUUM
|
|
// in purgeTargetRows.
|
|
func TestOpenPurgeRemovesCredentialBytes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := eventDBDataDir(t)
|
|
webhookID := uuid.New().String()
|
|
credential := "T00000000/B00000000/" + uuid.New().String()
|
|
|
|
initial := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err := initial.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, initial.CloseAll())
|
|
|
|
seed := openRawEventDB(t, dataDir, webhookID)
|
|
|
|
_, err = seed.ExecContext(
|
|
t.Context(),
|
|
"INSERT INTO targets "+
|
|
"(id, webhook_id, name, type, config) "+
|
|
"VALUES (?, '', ?, ?, ?)",
|
|
uuid.New().String(),
|
|
"leaked-target",
|
|
"slack",
|
|
fmt.Sprintf(
|
|
`{"webhookUrl":"https://hooks.example/%s"}`, credential,
|
|
),
|
|
)
|
|
require.NoError(t, err)
|
|
require.NoError(t, seed.Close())
|
|
|
|
// The seed has to be in the file for its absence later to mean
|
|
// anything.
|
|
require.True(
|
|
t,
|
|
bytes.Contains(
|
|
eventDBFileBytes(t, dataDir, webhookID),
|
|
[]byte(credential),
|
|
),
|
|
"seeded credential is not in the file, so this test proves nothing",
|
|
)
|
|
|
|
mgr := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err = mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, mgr.CloseAll())
|
|
|
|
assert.NotContains(
|
|
t,
|
|
string(eventDBFileBytes(t, dataDir, webhookID)),
|
|
credential,
|
|
"leaked credential is still recoverable from the raw file",
|
|
)
|
|
}
|
|
|
|
// TestOpenSucceedsWithoutTargetsTable covers an existing event
|
|
// database that never grew a targets table. The sweep must not fail
|
|
// startup on it.
|
|
func TestOpenSucceedsWithoutTargetsTable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := eventDBDataDir(t)
|
|
webhookID := uuid.New().String()
|
|
|
|
seed := openRawEventDB(t, dataDir, webhookID)
|
|
|
|
_, err := seed.ExecContext(
|
|
t.Context(),
|
|
"CREATE TABLE events (id text PRIMARY KEY)",
|
|
)
|
|
require.NoError(t, err)
|
|
require.NoError(t, seed.Close())
|
|
|
|
mgr := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
db, err := mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, db)
|
|
require.NoError(t, mgr.CloseAll())
|
|
}
|
|
|
|
// TestEventDBCreateOmitsAssociations covers the connection-level
|
|
// guard directly: a Delivery carrying its Event and Target in
|
|
// memory, written through the manager's handle, must store only the
|
|
// delivery row.
|
|
func TestEventDBCreateOmitsAssociations(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := eventDBDataDir(t)
|
|
webhookID := uuid.New().String()
|
|
|
|
mgr := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
db, err := mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
target := database.Target{
|
|
WebhookID: webhookID,
|
|
Name: "leaky-target",
|
|
Type: database.TargetTypeSlack,
|
|
Config: `{"webhookUrl":"https://hooks.example/secret"}`,
|
|
}
|
|
target.ID = uuid.New().String()
|
|
|
|
event := database.Event{
|
|
WebhookID: webhookID,
|
|
EntrypointID: uuid.New().String(),
|
|
Method: "POST",
|
|
Headers: `{}`,
|
|
Body: `{}`,
|
|
}
|
|
event.ID = uuid.New().String()
|
|
|
|
d := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: target.ID,
|
|
Status: database.DeliveryStatusPending,
|
|
Event: event,
|
|
Target: target,
|
|
}
|
|
d.ID = uuid.New().String()
|
|
|
|
require.NoError(t, db.Create(d).Error)
|
|
require.NoError(t, db.Model(d).
|
|
Update("status", database.DeliveryStatusDelivered).
|
|
Error)
|
|
require.NoError(t, mgr.CloseAll())
|
|
|
|
check := openRawEventDB(t, dataDir, webhookID)
|
|
assert.Zero(t, countTargetRows(t, check))
|
|
}
|