Files
Charon/backend/internal/api/handlers/db_health_handler_test.go
2026-03-04 18:34:49 +00:00

354 lines
10 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/database"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// createTestSQLiteDB creates a minimal valid SQLite database for testing
func createTestSQLiteDB(dbPath string) error {
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
defer func() { _ = sqlDB.Close() }()
// Create a simple table to make it a valid database
return db.Exec("CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, data TEXT)").Error
}
func TestDBHealthHandler_Check_Healthy(t *testing.T) {
gin.SetMode(gin.TestMode)
// Create in-memory database
db, err := database.Connect("file::memory:?cache=shared")
require.NoError(t, err)
handler := NewDBHealthHandler(db, nil)
router := gin.New()
router.GET("/api/v1/health/db", handler.Check)
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/db", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response DBHealthResponse
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "healthy", response.Status)
assert.True(t, response.IntegrityOK)
assert.Equal(t, "ok", response.IntegrityResult)
assert.NotEmpty(t, response.JournalMode)
assert.False(t, response.CheckedAt.IsZero())
}
func TestDBHealthHandler_Check_WithBackupService(t *testing.T) {
gin.SetMode(gin.TestMode)
// Setup temp dirs for backup service
tmpDir := t.TempDir()
dataDir := filepath.Join(tmpDir, "data")
err := os.MkdirAll(dataDir, 0o750) // #nosec G301 -- test directory
require.NoError(t, err)
// Create a valid SQLite database file for backup operations
dbPath := filepath.Join(dataDir, "charon.db")
err = createTestSQLiteDB(dbPath)
require.NoError(t, err)
cfg := &config.Config{DatabasePath: dbPath}
backupService := services.NewBackupService(cfg)
defer backupService.Stop() // Prevent goroutine leaks
// Create a backup so we have a last backup time
_, err = backupService.CreateBackup()
require.NoError(t, err)
// Create in-memory database for handler
db, err := database.Connect("file::memory:?cache=shared")
require.NoError(t, err)
handler := NewDBHealthHandler(db, backupService)
router := gin.New()
router.GET("/api/v1/health/db", handler.Check)
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/db", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response DBHealthResponse
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "healthy", response.Status)
assert.True(t, response.IntegrityOK)
assert.NotNil(t, response.LastBackup, "LastBackup should be set after creating a backup")
// Verify the backup time is recent
if response.LastBackup != nil {
assert.WithinDuration(t, time.Now(), *response.LastBackup, 5*time.Second)
}
}
func TestDBHealthHandler_Check_WALMode(t *testing.T) {
gin.SetMode(gin.TestMode)
// Create file-based database to test WAL mode
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := database.Connect(dbPath)
require.NoError(t, err)
handler := NewDBHealthHandler(db, nil)
router := gin.New()
router.GET("/api/v1/health/db", handler.Check)
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/db", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response DBHealthResponse
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "wal", response.JournalMode)
assert.True(t, response.WALMode)
}
func TestDBHealthHandler_ResponseJSONTags(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := database.Connect("file::memory:?cache=shared")
require.NoError(t, err)
handler := NewDBHealthHandler(db, nil)
router := gin.New()
router.GET("/api/v1/health/db", handler.Check)
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/db", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Verify JSON uses snake_case
body := w.Body.String()
assert.Contains(t, body, "integrity_ok")
assert.Contains(t, body, "integrity_result")
assert.Contains(t, body, "wal_mode")
assert.Contains(t, body, "journal_mode")
assert.Contains(t, body, "last_backup")
assert.Contains(t, body, "checked_at")
// Verify no camelCase leak
assert.NotContains(t, body, "integrityOK")
assert.NotContains(t, body, "journalMode")
assert.NotContains(t, body, "lastBackup")
assert.NotContains(t, body, "checkedAt")
}
func TestNewDBHealthHandler(t *testing.T) {
db, err := database.Connect("file::memory:?cache=shared")
require.NoError(t, err)
handler := NewDBHealthHandler(db, nil)
assert.NotNil(t, handler)
assert.Equal(t, db, handler.db)
assert.Nil(t, handler.backupService)
// With backup service
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "charon.db")
_ = os.WriteFile(dbPath, []byte("test"), 0o600) // #nosec G306 -- test fixture
cfg := &config.Config{DatabasePath: dbPath}
backupSvc := services.NewBackupService(cfg)
defer backupSvc.Stop() // Prevent goroutine leaks
handler2 := NewDBHealthHandler(db, backupSvc)
assert.NotNil(t, handler2.backupService)
}
// Phase 1 & 3: Critical coverage tests
func TestDBHealthHandler_Check_CorruptedDatabase(t *testing.T) {
gin.SetMode(gin.TestMode)
// Create a file-based database and corrupt it
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "corrupt.db")
// Create valid database first
db, err := database.Connect(dbPath)
require.NoError(t, err)
db.Exec("CREATE TABLE test (id INTEGER, data TEXT)")
db.Exec("INSERT INTO test VALUES (1, 'data')")
// Close it
sqlDB, _ := db.DB()
_ = sqlDB.Close()
// Corrupt the database file
corruptDBFile(t, dbPath)
// Try to reconnect to corrupted database
db2, err := database.Connect(dbPath)
// The Connect function may succeed initially but integrity check will fail
if err != nil {
// If connection fails immediately, skip this test
t.Skip("Database connection failed immediately on corruption")
}
handler := NewDBHealthHandler(db2, nil)
router := gin.New()
router.GET("/api/v1/health/db", handler.Check)
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/db", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Should return 503 if corruption detected
if w.Code == http.StatusServiceUnavailable {
var response DBHealthResponse
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "corrupted", response.Status)
assert.False(t, response.IntegrityOK)
assert.NotEqual(t, "ok", response.IntegrityResult)
} else {
// If status is 200, corruption wasn't detected by quick_check
// (corruption might be in unused pages)
assert.Equal(t, http.StatusOK, w.Code)
}
}
func TestDBHealthHandler_Check_BackupServiceError(t *testing.T) {
gin.SetMode(gin.TestMode)
// Create database
db, err := database.Connect("file::memory:?cache=shared")
require.NoError(t, err)
// Create backup service with unreadable directory
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "charon.db")
_ = os.WriteFile(dbPath, []byte("test"), 0o600) // #nosec G306 -- test fixture
cfg := &config.Config{DatabasePath: dbPath}
backupService := services.NewBackupService(cfg)
// Make backup directory unreadable to trigger error in GetLastBackupTime
_ = os.Chmod(backupService.BackupDir, 0o000)
// #nosec G302 -- Test cleanup restores directory permissions
defer func() { _ = os.Chmod(backupService.BackupDir, 0o755) }() // Restore for cleanup
handler := NewDBHealthHandler(db, backupService)
router := gin.New()
router.GET("/api/v1/health/db", handler.Check)
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/db", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Handler should still succeed (backup error is swallowed)
assert.Equal(t, http.StatusOK, w.Code)
var response DBHealthResponse
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Status should be healthy despite backup service error
assert.Equal(t, "healthy", response.Status)
// LastBackup should be nil when error occurs
assert.Nil(t, response.LastBackup)
}
func TestDBHealthHandler_Check_BackupTimeZero(t *testing.T) {
gin.SetMode(gin.TestMode)
// Create database
db, err := database.Connect("file::memory:?cache=shared")
require.NoError(t, err)
// Create backup service with empty backup directory (no backups yet)
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "charon.db")
_ = os.WriteFile(dbPath, []byte("test"), 0o600) // #nosec G306 -- test fixture
cfg := &config.Config{DatabasePath: dbPath}
backupService := services.NewBackupService(cfg)
handler := NewDBHealthHandler(db, backupService)
router := gin.New()
router.GET("/api/v1/health/db", handler.Check)
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/db", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response DBHealthResponse
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// LastBackup should be nil when no backups exist (zero time)
assert.Nil(t, response.LastBackup)
assert.Equal(t, "healthy", response.Status)
}
// Helper function to corrupt SQLite database file
func corruptDBFile(t *testing.T, dbPath string) {
t.Helper()
// #nosec G302 -- Test opens database file for corruption testing
f, err := os.OpenFile(dbPath, os.O_RDWR, 0o644) //nolint:gosec // G304: Database file for corruption test
require.NoError(t, err)
defer func() { _ = f.Close() }()
// Get file size
stat, err := f.Stat()
require.NoError(t, err)
size := stat.Size()
if size > 100 {
// Overwrite middle section to corrupt B-tree
_, err = f.WriteAt([]byte("CORRUPTED_BLOCK_DATA"), size/2)
require.NoError(t, err)
} else {
// Corrupt header for small files
_, err = f.WriteAt([]byte("CORRUPT"), 0)
require.NoError(t, err)
}
}