Ctrl K
ComunidadGuías…

server/internal/db/db_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
}