Files
Charon/backend/internal/database/settings_query_test.go
2026-03-04 18:34:49 +00:00

241 lines
8.5 KiB
Go

package database
import (
"testing"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// setupTestDB creates an in-memory SQLite database for testing
func setupTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
// Auto-migrate the Setting model
err = db.AutoMigrate(&models.Setting{})
require.NoError(t, err)
return db
}
// TestSettingsQueryWithDifferentIDs verifies that reusing a models.Setting variable
// without resetting it causes GORM to include the previous record's ID in subsequent
// WHERE clauses, resulting in "record not found" errors.
func TestSettingsQueryWithDifferentIDs(t *testing.T) {
db := setupTestDB(t)
// Create settings with different IDs
setting1 := &models.Setting{Key: "feature.cerberus.enabled", Value: "true"}
err := db.Create(setting1).Error
require.NoError(t, err)
assert.Equal(t, uint(1), setting1.ID)
setting2 := &models.Setting{Key: "security.acl.enabled", Value: "true"}
err = db.Create(setting2).Error
require.NoError(t, err)
assert.Equal(t, uint(2), setting2.ID)
// Simulate the bug: reuse variable without reset
var s models.Setting
// First query - populates s.ID = 1
err = db.Where("key = ?", "feature.cerberus.enabled").First(&s).Error
require.NoError(t, err)
assert.Equal(t, uint(1), s.ID)
assert.Equal(t, "feature.cerberus.enabled", s.Key)
// Second query WITHOUT reset - demonstrates the bug
// This would fail with "record not found" if the bug exists
// because it queries: WHERE key='security.acl.enabled' AND id=1
t.Run("without reset should fail with bug", func(t *testing.T) {
var sNoBugFix models.Setting
// First query
err := db.Where("key = ?", "feature.cerberus.enabled").First(&sNoBugFix).Error
require.NoError(t, err)
// Second query without reset - this is the bug scenario
err = db.Where("key = ?", "security.acl.enabled").First(&sNoBugFix).Error
// With the bug, this would fail with gorm.ErrRecordNotFound
// After the fix (struct reset in production code), this should succeed
// But in this test, we're demonstrating what WOULD happen without reset
if err == nil {
// Bug is present but not triggered (both records have same ID somehow)
// Or the production code has the fix
t.Logf("Query succeeded - either fix is applied or test setup issue")
} else {
// This is expected without the reset
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
}
})
// Third query WITH reset - should always work (this is the fix)
t.Run("with reset should always work", func(t *testing.T) {
var sWithFix models.Setting
// First query
err := db.Where("key = ?", "feature.cerberus.enabled").First(&sWithFix).Error
require.NoError(t, err)
// Reset the struct (THE FIX)
sWithFix = models.Setting{}
// Second query with reset - should work
err = db.Where("key = ?", "security.acl.enabled").First(&sWithFix).Error
require.NoError(t, err)
assert.Equal(t, uint(2), sWithFix.ID)
assert.Equal(t, "security.acl.enabled", sWithFix.Key)
})
}
// TestCaddyManagerSecuritySettings simulates the real-world scenario from
// manager.go where multiple security settings are queried in sequence.
// This test verifies that non-sequential IDs don't cause query failures.
func TestCaddyManagerSecuritySettings(t *testing.T) {
db := setupTestDB(t)
// Create all security settings with specific IDs to simulate real database
// where settings are created/deleted/recreated over time
// We need to create them in a transaction and manually set IDs
// Create with ID gaps to simulate real scenario
settings := []models.Setting{
{ID: 4, Key: "feature.cerberus.enabled", Value: "true"},
{ID: 6, Key: "security.acl.enabled", Value: "true"},
{ID: 8, Key: "security.waf.enabled", Value: "true"},
}
for _, setting := range settings {
// Use Session to allow manual ID assignment
err := db.Session(&gorm.Session{FullSaveAssociations: false}).Create(&setting).Error
require.NoError(t, err)
}
// Simulate the query pattern from manager.go buildSecurityConfig()
var s models.Setting
// Query 1: Cerberus (ID=4)
cerbEnabled := false
if err := db.Where("key = ?", "feature.cerberus.enabled").First(&s).Error; err == nil {
cerbEnabled = s.Value == "true"
}
require.True(t, cerbEnabled)
assert.Equal(t, uint(4), s.ID, "Cerberus query should return ID=4")
// Query 2: ACL (ID=6) - WITHOUT reset this would fail
// With the fix applied in manager.go, struct should be reset here
s = models.Setting{} // THE FIX
aclEnabled := false
err := db.Where("key = ?", "security.acl.enabled").First(&s).Error
require.NoError(t, err, "ACL query should not fail with 'record not found'")
if err == nil {
aclEnabled = s.Value == "true"
}
require.True(t, aclEnabled)
assert.Equal(t, uint(6), s.ID, "ACL query should return ID=6")
// Query 3: WAF (ID=8) - should also work with reset
s = models.Setting{} // THE FIX
wafEnabled := false
if err := db.Where("key = ?", "security.waf.enabled").First(&s).Error; err == nil {
wafEnabled = s.Value == "true"
}
require.True(t, wafEnabled)
assert.Equal(t, uint(8), s.ID, "WAF query should return ID=8")
}
// TestUptimeMonitorSettingsReuse verifies the ticker loop scenario from routes.go
// where the same variable is reused across multiple query iterations.
// This test simulates what happens when a setting is deleted and recreated.
func TestUptimeMonitorSettingsReuse(t *testing.T) {
db := setupTestDB(t)
setting := &models.Setting{Key: "feature.uptime.enabled", Value: "true"}
err := db.Create(setting).Error
require.NoError(t, err)
firstID := setting.ID
// First query - simulates initial check before ticker starts
var s models.Setting
err = db.Where("key = ?", "feature.uptime.enabled").First(&s).Error
require.NoError(t, err)
assert.Equal(t, firstID, s.ID)
assert.Equal(t, "true", s.Value)
// Simulate setting being deleted and recreated (e.g., during migration or manual change)
err = db.Delete(setting).Error
require.NoError(t, err)
newSetting := &models.Setting{Key: "feature.uptime.enabled", Value: "true"}
err = db.Create(newSetting).Error
require.NoError(t, err)
newID := newSetting.ID
assert.NotEqual(t, firstID, newID, "New record should have different ID")
// Second query WITH reset - simulates ticker loop iteration with fix
s = models.Setting{} // THE FIX
err = db.Where("key = ?", "feature.uptime.enabled").First(&s).Error
require.NoError(t, err, "Query should find new record after reset")
assert.Equal(t, newID, s.ID, "Should find new record with new ID")
assert.Equal(t, "true", s.Value)
// Third iteration - verify reset works across multiple ticks
s = models.Setting{} // THE FIX
err = db.Where("key = ?", "feature.uptime.enabled").First(&s).Error
require.NoError(t, err)
assert.Equal(t, newID, s.ID)
}
// TestSettingsQueryBugDemonstration explicitly demonstrates the bug scenario
// This test documents the expected behavior BEFORE and AFTER the fix
func TestSettingsQueryBugDemonstration(t *testing.T) {
db := setupTestDB(t)
// Setup: Create two settings with different IDs
db.Create(&models.Setting{Key: "setting.one", Value: "value1"}) // ID=1
db.Create(&models.Setting{Key: "setting.two", Value: "value2"}) // ID=2
t.Run("bug scenario - no reset", func(t *testing.T) {
var s models.Setting
// Query 1: Gets setting.one (ID=1)
err := db.Where("key = ?", "setting.one").First(&s).Error
require.NoError(t, err)
assert.Equal(t, uint(1), s.ID)
// Query 2: Try to get setting.two (ID=2)
// WITHOUT reset, s.ID is still 1, so GORM generates:
// SELECT * FROM settings WHERE key = 'setting.two' AND id = 1
// This fails because no record matches both conditions
err = db.Where("key = ?", "setting.two").First(&s).Error
// This assertion documents the bug behavior
if err != nil {
assert.ErrorIs(t, err, gorm.ErrRecordNotFound,
"Bug causes 'record not found' because GORM includes ID=1 in WHERE clause")
}
})
t.Run("fixed scenario - with reset", func(t *testing.T) {
var s models.Setting
// Query 1: Gets setting.one (ID=1)
err := db.Where("key = ?", "setting.one").First(&s).Error
require.NoError(t, err)
assert.Equal(t, uint(1), s.ID)
// THE FIX: Reset struct before next query
s = models.Setting{}
// Query 2: Get setting.two (ID=2)
// After reset, GORM generates correct query:
// SELECT * FROM settings WHERE key = 'setting.two'
err = db.Where("key = ?", "setting.two").First(&s).Error
require.NoError(t, err, "With reset, query should succeed")
assert.Equal(t, uint(2), s.ID, "Should find the correct record")
})
}