package db_test import ( "context" "os" "path/filepath" "runtime" "strings" "testing" "time" "blackbox/server/internal/db" "blackbox/server/internal/models" "blackbox/shared/types" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "gorm.io/gorm/logger" ) type sqliteIndexInfoRow struct { Seqno int `gorm:"column:seqno"` Name string `gorm:"column:name"` } func closeDBOnCleanup(t *testing.T, database *gorm.DB) { t.Helper() t.Cleanup(func() { sqlDB, err := database.DB() require.NoError(t, err) require.NoError(t, sqlDB.Close()) }) } func TestInit_CreatesTablesAndMigrates(t *testing.T) { tmp, err := os.CreateTemp("", "blackbox-test-*.db") require.NoError(t, err) require.NoError(t, tmp.Close()) t.Cleanup(func() { require.NoError(t, os.Remove(tmp.Name())) }) database, err := db.Init(tmp.Name()) require.NoError(t, err) assert.NotNil(t, database) closeDBOnCleanup(t, database) user := models.User{ID: "01TESTUSER", Username: "admin", IsAdmin: true} assert.NoError(t, database.Create(&user).Error) var found models.User assert.NoError(t, database.First(&found, "username = ?", "admin").Error) assert.Equal(t, "admin", found.Username) assert.True(t, found.IsAdmin) entry := types.Entry{ID: "01TESTENTRY", NodeName: "node1", Source: "manual", Event: "test"} assert.NoError(t, database.Create(&entry).Error) } func TestIncidentTablesExist(t *testing.T) { database, err := db.Init(":memory:") require.NoError(t, err) closeDBOnCleanup(t, database) assert.True(t, database.Migrator().HasTable(&models.Incident{})) assert.True(t, database.Migrator().HasTable(&models.IncidentEntry{})) } func TestInit_CreatesCompositeEntryCursorIndex(t *testing.T) { tmp, err := os.CreateTemp("", "blackbox-test-*.db") require.NoError(t, err) require.NoError(t, tmp.Close()) t.Cleanup(func() { require.NoError(t, os.Remove(tmp.Name())) }) database, err := db.Init(tmp.Name()) require.NoError(t, err) closeDBOnCleanup(t, database) require.True(t, database.Migrator().HasIndex(&types.Entry{}, "idx_entries_timestamp_id")) require.False(t, database.Migrator().HasIndex(&types.Entry{}, "idx_entries_timestamp")) var columns []sqliteIndexInfoRow require.NoError(t, database.Raw(`PRAGMA index_info('idx_entries_timestamp_id')`).Scan(&columns).Error) require.Len(t, columns, 2) assert.Equal(t, "timestamp", columns[0].Name) assert.Equal(t, "id", columns[1].Name) } func TestInit_EnforcesSingletonDataSourceUniqueness(t *testing.T) { database, err := db.Init(":memory:") require.NoError(t, err) closeDBOnCleanup(t, database) nodeName := "node-1" require.NoError(t, database.Create(&models.Node{ID: "n1", Name: nodeName, Capabilities: "[]"}).Error) require.NoError(t, database.Create(&models.DataSourceInstance{ ID: "sys-1", Type: "systemd", Scope: "agent", NodeID: &nodeName, Name: "Systemd", Config: "{}", }).Error) err = database.Create(&models.DataSourceInstance{ ID: "sys-2", Type: "systemd", Scope: "agent", NodeID: &nodeName, Name: "Systemd 2", Config: "{}", }).Error require.Error(t, err) sysErr := strings.ToLower(err.Error()) require.Truef(t, strings.Contains(sysErr, "unique") || strings.Contains(sysErr, "duplicate"), "unexpected duplicate error: %v", err) require.NoError(t, database.Create(&models.DataSourceInstance{ ID: "wh-1", Type: "webhook_uptime_kuma", Scope: "server", Name: "UK", Config: "{}", }).Error) err = database.Create(&models.DataSourceInstance{ ID: "wh-2", Type: "webhook_uptime_kuma", Scope: "server", Name: "UK 2", Config: "{}", }).Error require.Error(t, err) webhookErr := strings.ToLower(err.Error()) require.Truef(t, strings.Contains(webhookErr, "unique") || strings.Contains(webhookErr, "duplicate"), "unexpected duplicate error: %v", err) } func TestInit_CascadesAgentScopedDataSourcesWhenNodeDeleted(t *testing.T) { database, err := db.Init(":memory:") require.NoError(t, err) closeDBOnCleanup(t, database) nodeName := "node-1" require.NoError(t, database.Create(&models.Node{ID: "n1", Name: nodeName, Capabilities: "[]"}).Error) require.NoError(t, database.Create(&models.DataSourceInstance{ ID: "sys-1", Type: "systemd", Scope: "agent", NodeID: &nodeName, Name: "Systemd", Config: "{}", }).Error) require.NoError(t, database.Create(&models.DataSourceInstance{ ID: "srv-1", Type: "webhook_uptime_kuma", Scope: "server", Name: "UK", Config: "{}", }).Error) require.NoError(t, database.Delete(&models.Node{}, "name = ?", nodeName).Error) var count int64 require.NoError(t, database.Model(&models.DataSourceInstance{}).Where("id = ?", "sys-1").Count(&count).Error) require.Equal(t, int64(0), count) require.NoError(t, database.Model(&models.DataSourceInstance{}).Where("id = ?", "srv-1").Count(&count).Error) require.Equal(t, int64(1), count) } func TestInit_DropsLegacyTimestampOnlyEntryIndex(t *testing.T) { tmp, err := os.CreateTemp("", "blackbox-test-*.db") require.NoError(t, err) require.NoError(t, tmp.Close()) t.Cleanup(func() { require.NoError(t, os.Remove(tmp.Name())) }) legacyDB, err := gorm.Open(sqlite.Open(tmp.Name()), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) require.NoError(t, err) require.NoError(t, legacyDB.Exec(` CREATE TABLE entries ( id TEXT PRIMARY KEY, timestamp DATETIME, node_name TEXT, source TEXT, service TEXT, event TEXT, content TEXT, metadata TEXT, correlated_id TEXT ) `).Error) require.NoError(t, legacyDB.Exec(`CREATE INDEX idx_entries_timestamp ON entries(timestamp)`).Error) require.True(t, legacyDB.Migrator().HasIndex(&types.Entry{}, "idx_entries_timestamp")) sqlDB, err := legacyDB.DB() require.NoError(t, err) require.NoError(t, sqlDB.Close()) database, err := db.Init(tmp.Name()) require.NoError(t, err) closeDBOnCleanup(t, database) require.True(t, database.Migrator().HasIndex(&types.Entry{}, "idx_entries_timestamp_id")) require.False(t, database.Migrator().HasIndex(&types.Entry{}, "idx_entries_timestamp")) } func TestInit_MigratesInviteCodeOIDCStateAndOIDCConfig(t *testing.T) { tmp, err := os.CreateTemp("", "blackbox-test-*.db") require.NoError(t, err) require.NoError(t, tmp.Close()) t.Cleanup(func() { require.NoError(t, os.Remove(tmp.Name())) }) database, err := db.Init(tmp.Name()) require.NoError(t, err) closeDBOnCleanup(t, database) invite := models.InviteCode{ ID: "01INVITEID0000000", Code: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", CreatedBy: "01ADMINID0000000", ExpiresAt: time.Now().Add(72 * time.Hour), CreatedAt: time.Now(), } assert.NoError(t, database.Create(&invite).Error) state := models.OIDCState{ ID: "01STATEID00000000", State: "randomstate123456789012345678901234567890123456789012345678901234", Nonce: "randomnonce123456789012345678901234567890123456789012345678901234", ProviderID: "01PROVIDERID000000", InviteCode: "invite-code-123", ExpiresAt: time.Now().Add(10 * time.Minute), CreatedAt: time.Now(), } assert.NoError(t, database.Create(&state).Error) provider := models.OIDCProviderConfig{ ID: "01PROVIDERID000000", Name: "SSO", Issuer: "https://issuer.example.com", ClientID: "client-id", ClientSecret: "client-secret", RedirectURL: "https://app.example.com/callback", Enabled: models.BoolPtr(true), } assert.NoError(t, database.Create(&provider).Error) setting := models.AppSetting{ Key: "oidc_policy", Value: "open", UpdatedAt: time.Now(), } assert.NoError(t, database.Create(&setting).Error) } func TestInit_CreatesMissingDatabasePath(t *testing.T) { root := t.TempDir() dbPath := filepath.Join(root, "nested", "blackbox.db") database, err := db.Init(dbPath) require.NoError(t, err) assert.NotNil(t, database) closeDBOnCleanup(t, database) info, err := os.Stat(dbPath) require.NoError(t, err) assert.False(t, info.IsDir()) } func TestInit_ReturnsHelpfulErrorForReadOnlyDirectory(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Windows directory mode bits do not reliably simulate a read-only directory for this test") } if os.Getuid() == 0 { t.Skip("root can bypass directory permissions") } root := t.TempDir() locked := filepath.Join(root, "locked") require.NoError(t, os.Mkdir(locked, 0o555)) _, err := db.Init(filepath.Join(locked, "blackbox.db")) require.Error(t, err) assert.Contains(t, err.Error(), "database directory") assert.Contains(t, err.Error(), "uid=") assert.Contains(t, err.Error(), "gid=") } func TestStartOIDCStateSweeper_DeletesExpiredStatesImmediately(t *testing.T) { database, err := db.Init(":memory:") require.NoError(t, err) closeDBOnCleanup(t, database) expired := models.OIDCState{ ID: "01STATEEXPIRED0000", State: "expiredstate1234567890123456789012345678901234567890123456789012", Nonce: "expirednonce1234567890123456789012345678901234567890123456789012", ProviderID: "01PROVIDEREXPIRED0", ExpiresAt: time.Now().Add(-time.Minute), CreatedAt: time.Now().Add(-2 * time.Minute), } fresh := models.OIDCState{ ID: "01STATEFRESH000000", State: "freshstate123456789012345678901234567890123456789012345678901234", Nonce: "freshnonce123456789012345678901234567890123456789012345678901234", ProviderID: "01PROVIDERFRESH000", ExpiresAt: time.Now().Add(time.Hour), CreatedAt: time.Now(), } require.NoError(t, database.Create(&expired).Error) require.NoError(t, database.Create(&fresh).Error) ctx, cancel := context.WithCancel(context.Background()) defer cancel() db.StartOIDCStateSweeper(ctx, database) require.Eventually(t, func() bool { var expiredCount int64 if err := database.Model(&models.OIDCState{}).Where("id = ?", expired.ID).Count(&expiredCount).Error; err != nil { return false } return expiredCount == 0 }, time.Second, 10*time.Millisecond) var freshCount int64 require.NoError(t, database.Model(&models.OIDCState{}).Where("id = ?", fresh.ID).Count(&freshCount).Error) assert.Equal(t, int64(1), freshCount) } func TestStartNotificationLogSweeper_PrunesOldButKeepsPendingHeld(t *testing.T) { database, err := db.Init(":memory:") require.NoError(t, err) closeDBOnCleanup(t, database) old := time.Now().Add(-72 * time.Hour) flushedAt := old.Add(time.Hour) rows := []models.NotificationLog{ {ID: "log-old-sent", DestID: "d1", Decision: "sent", CreatedAt: old}, {ID: "log-old-held-flushed", DestID: "d1", Decision: "held", FlushedAt: &flushedAt, CreatedAt: old}, {ID: "log-old-held-pending", DestID: "d1", Decision: "held", CreatedAt: old}, {ID: "log-fresh-sent", DestID: "d1", Decision: "sent", CreatedAt: time.Now()}, } for i := range rows { require.NoError(t, database.Create(&rows[i]).Error) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() db.StartNotificationLogSweeper(ctx, database) require.Eventually(t, func() bool { var oldSent int64 if err := database.Model(&models.NotificationLog{}).Where("id = ?", "log-old-sent").Count(&oldSent).Error; err != nil { return false } return oldSent == 0 }, time.Second, 10*time.Millisecond) exists := func(id string) int64 { var n int64 require.NoError(t, database.Model(&models.NotificationLog{}).Where("id = ?", id).Count(&n).Error) return n } assert.Equal(t, int64(0), exists("log-old-sent"), "old sent row should be pruned") assert.Equal(t, int64(0), exists("log-old-held-flushed"), "old flushed held row should be pruned") assert.Equal(t, int64(1), exists("log-old-held-pending"), "pending held row must be preserved") assert.Equal(t, int64(1), exists("log-fresh-sent"), "fresh row must be preserved") } func TestInit_FileDB_WALModeEnabled(t *testing.T) { tmp, err := os.CreateTemp("", "blackbox-wal-test-*.db") require.NoError(t, err) require.NoError(t, tmp.Close()) t.Cleanup(func() { require.NoError(t, os.Remove(tmp.Name())) }) database, err := db.Init(tmp.Name()) require.NoError(t, err) closeDBOnCleanup(t, database) var mode string require.NoError(t, database.Raw("PRAGMA journal_mode").Scan(&mode).Error) assert.Equal(t, "wal", mode) } func TestInit_FileDB_BusyTimeoutSet(t *testing.T) { tmp, err := os.CreateTemp("", "blackbox-busy-test-*.db") require.NoError(t, err) require.NoError(t, tmp.Close()) t.Cleanup(func() { require.NoError(t, os.Remove(tmp.Name())) }) database, err := db.Init(tmp.Name()) require.NoError(t, err) closeDBOnCleanup(t, database) var timeout int require.NoError(t, database.Raw("PRAGMA busy_timeout").Scan(&timeout).Error) assert.Equal(t, 5000, timeout) } func TestInit_FileDB_SingleOpenConnection(t *testing.T) { tmp, err := os.CreateTemp("", "blackbox-conn-test-*.db") require.NoError(t, err) require.NoError(t, tmp.Close()) t.Cleanup(func() { require.NoError(t, os.Remove(tmp.Name())) }) database, err := db.Init(tmp.Name()) require.NoError(t, err) closeDBOnCleanup(t, database) sqlDB, err := database.DB() require.NoError(t, err) stats := sqlDB.Stats() assert.Equal(t, 1, stats.MaxOpenConnections) }