package database_test import ( "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 } // 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)) } // 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)) }