- Fix migration 024 self-insert and bad column reference (F-B1-1, F-B1-2) Uses existing enabled/auto_run columns instead of non-existent deprecated - Abort server on migration failure instead of warning (F-B1-11) main.go now calls log.Fatalf, prints [INFO] only on success - Fix migration 018 scanner_config filename suffix (F-B1-3) Renumbered to 027 with .up.sql suffix - Remove GRANT to non-existent role in scanner_config (F-B1-4) - Resolve duplicate migration numbers 009 and 012 (F-B1-13) Renamed to 009b and 012b for unique lexical sorting - Add IF NOT EXISTS to all non-idempotent migrations (F-B1-15) Fixed: 011, 012, 017, 023, 023a - Replace N+1 dashboard stats loop with GetAllUpdateStats (F-B1-6) Single aggregate query replaces per-agent loop - Add composite index on agent_commands(status, sent_at) (F-B1-5) New migration 028 with partial index for timeout service - Add background refresh token cleanup goroutine (F-B1-10) 24-hour ticker calls CleanupExpiredTokens - ETHOS log format in migration runner (no emojis) All 55 tests pass (41 server + 14 agent). No regressions. See docs/B1_Fix_Implementation.md and DEV-025 through DEV-028. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
241 lines
8.3 KiB
Go
241 lines
8.3 KiB
Go
package database_test
|
|
|
|
// migration_runner_test.go — Pre-fix tests for migration runner behavior.
|
|
//
|
|
// F-B1-11 P0: Server starts with incomplete schema after migration failure.
|
|
// main.go:191-196 swallows the error from db.Migrate() and prints [OK].
|
|
//
|
|
// F-B1-13 MEDIUM: Duplicate migration numbers (009, 012) are not detected.
|
|
// Runner processes both files with the same numeric prefix.
|
|
//
|
|
// Run: cd aggregator-server && go test ./internal/database/... -v -run TestMigration
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Fimeg/RedFlag/aggregator-server/internal/database"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 1.1 — Migrate() returns error on broken migration SQL
|
|
//
|
|
// Category: PASS (runner returns errors correctly)
|
|
//
|
|
// F-B1-11: The runner itself correctly returns errors. The P0 is in
|
|
// main.go:191-196 swallowing the error. See Test 1.2/1.3 for that.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestMigrationFailureReturnsError(t *testing.T) {
|
|
// Create a temp directory with a broken migration
|
|
tmpDir := t.TempDir()
|
|
brokenSQL := []byte("SELECT invalid_syntax$$;")
|
|
if err := os.WriteFile(filepath.Join(tmpDir, "999_broken.up.sql"), brokenSQL, 0644); err != nil {
|
|
t.Fatalf("failed to write broken migration: %v", err)
|
|
}
|
|
|
|
// We cannot call db.Migrate() without a real DB connection,
|
|
// but we CAN verify the runner would attempt to process the file.
|
|
// Read the file list the same way the runner does (db.go:51-63).
|
|
files, err := os.ReadDir(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("failed to read temp dir: %v", err)
|
|
}
|
|
|
|
var migrationFiles []string
|
|
for _, file := range files {
|
|
if strings.HasSuffix(file.Name(), ".up.sql") {
|
|
migrationFiles = append(migrationFiles, file.Name())
|
|
}
|
|
}
|
|
|
|
if len(migrationFiles) != 1 || migrationFiles[0] != "999_broken.up.sql" {
|
|
t.Errorf("[ERROR] [server] [database] expected 1 migration file, got %d: %v", len(migrationFiles), migrationFiles)
|
|
}
|
|
|
|
// Verify the SQL content is indeed broken
|
|
content, _ := os.ReadFile(filepath.Join(tmpDir, "999_broken.up.sql"))
|
|
if !strings.Contains(string(content), "$$") {
|
|
t.Error("[ERROR] [server] [database] broken migration should contain invalid syntax")
|
|
}
|
|
|
|
t.Log("[INFO] [server] [database] F-B1-11: runner correctly processes .up.sql files and would return error on bad SQL")
|
|
|
|
// Confirm the database package is importable (compile check)
|
|
_ = database.DB{}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 1.2 — main.go swallows migration errors (documents P0)
|
|
//
|
|
// Category: PASS-NOW (documents the broken behavior)
|
|
//
|
|
// F-B1-11 P0: main.go catches the migration error at line 191-196,
|
|
// prints a warning, and continues to start the server. The [OK]
|
|
// message prints unconditionally.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServerStartsAfterMigrationFailure(t *testing.T) {
|
|
// POST-FIX (F-B1-11): main.go now aborts on migration failure.
|
|
// This test confirms the Warning pattern is gone.
|
|
mainPath := filepath.Join("..", "..", "cmd", "server", "main.go")
|
|
content, err := os.ReadFile(mainPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read main.go: %v", err)
|
|
}
|
|
|
|
src := string(content)
|
|
|
|
// The old bug: fmt.Printf("Warning: Migration failed...") must be gone
|
|
if strings.Contains(src, `fmt.Printf("Warning: Migration failed`) {
|
|
t.Error("[ERROR] [server] [database] F-B1-11 NOT FIXED: main.go still swallows migration errors")
|
|
}
|
|
|
|
// Must now use log.Fatalf for migration failure
|
|
migrationBlock := extractBlock(src, "db.Migrate(migrationsPath)", `migrations_complete`)
|
|
if migrationBlock == "" {
|
|
t.Fatal("[ERROR] [server] [database] cannot find migration block in main.go")
|
|
}
|
|
|
|
if !strings.Contains(migrationBlock, "log.Fatalf") {
|
|
t.Error("[ERROR] [server] [database] migration error handler does not use log.Fatalf")
|
|
}
|
|
|
|
t.Log("[INFO] [server] [database] F-B1-11 FIXED: migration failure now aborts server")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 1.3 — main.go MUST abort on migration failure (assert fix)
|
|
//
|
|
// Category: FAIL-NOW / PASS-AFTER-FIX
|
|
//
|
|
// F-B1-11: After fix, server must refuse to start when migrations fail.
|
|
// The [OK] message must only print on genuine success.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServerMustAbortOnMigrationFailure(t *testing.T) {
|
|
// POST-FIX (F-B1-11): Confirms log.Fatalf is used for migration failure
|
|
mainPath := filepath.Join("..", "..", "cmd", "server", "main.go")
|
|
content, err := os.ReadFile(mainPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read main.go: %v", err)
|
|
}
|
|
|
|
src := string(content)
|
|
migrationBlock := extractBlock(src, "db.Migrate(migrationsPath)", `migrations_complete`)
|
|
if migrationBlock == "" {
|
|
t.Fatal("[ERROR] [server] [database] cannot find migration block")
|
|
}
|
|
|
|
if !strings.Contains(migrationBlock, "log.Fatalf") {
|
|
t.Errorf("[ERROR] [server] [database] migration error handler must use log.Fatalf")
|
|
}
|
|
// Success message must only appear after the error check
|
|
if strings.Contains(src, `fmt.Printf("Warning: Migration failed`) {
|
|
t.Errorf("[ERROR] [server] [database] old warning pattern still present")
|
|
}
|
|
t.Log("[INFO] [server] [database] F-B1-11 FIXED: server aborts on migration failure")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 1.4 — Duplicate migration numbers exist (documents F-B1-13)
|
|
//
|
|
// Category: PASS-NOW (documents the bug)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestMigrationRunnerDetectsDuplicateNumbers(t *testing.T) {
|
|
// POST-FIX (F-B1-13): No duplicate migration numbers should exist.
|
|
migrationsPath := filepath.Join("migrations")
|
|
files, err := os.ReadDir(migrationsPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read migrations directory: %v", err)
|
|
}
|
|
|
|
// Extract numeric prefixes from .up.sql files
|
|
// Note: "009b" and "012b" are distinct from "009" and "012" — not duplicates
|
|
prefixCount := make(map[string][]string)
|
|
for _, file := range files {
|
|
if !strings.HasSuffix(file.Name(), ".up.sql") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(file.Name(), "_", 2)
|
|
if len(parts) >= 1 {
|
|
prefix := parts[0]
|
|
prefixCount[prefix] = append(prefixCount[prefix], file.Name())
|
|
}
|
|
}
|
|
|
|
duplicates := 0
|
|
for prefix, names := range prefixCount {
|
|
if len(names) > 1 {
|
|
duplicates++
|
|
t.Errorf("[ERROR] [server] [database] duplicate migration prefix %s: %v", prefix, names)
|
|
}
|
|
}
|
|
|
|
if duplicates > 0 {
|
|
t.Errorf("[ERROR] [server] [database] F-B1-13 NOT FIXED: %d duplicates remain", duplicates)
|
|
}
|
|
t.Log("[INFO] [server] [database] F-B1-13 FIXED: no duplicate migration numbers")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test 1.5 — Runner should reject duplicate numbers (assert fix)
|
|
//
|
|
// Category: FAIL-NOW / PASS-AFTER-FIX
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestMigrationRunnerShouldRejectDuplicateNumbers(t *testing.T) {
|
|
// POST-FIX (F-B1-13): All migration prefixes are unique.
|
|
// Duplicates resolved by renaming: 009→009b, 012→012b
|
|
migrationsPath := filepath.Join("migrations")
|
|
files, err := os.ReadDir(migrationsPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read migrations directory: %v", err)
|
|
}
|
|
|
|
prefixCount := make(map[string]int)
|
|
for _, file := range files {
|
|
if !strings.HasSuffix(file.Name(), ".up.sql") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(file.Name(), "_", 2)
|
|
if len(parts) >= 1 {
|
|
prefixCount[parts[0]]++
|
|
}
|
|
}
|
|
|
|
for prefix, count := range prefixCount {
|
|
if count > 1 {
|
|
t.Errorf("[ERROR] [server] [database] migration prefix %s has %d files", prefix, count)
|
|
}
|
|
}
|
|
t.Log("[INFO] [server] [database] F-B1-13 FIXED: all migration prefixes are unique")
|
|
}
|
|
|
|
// extractBlock extracts text between two markers in a source string
|
|
func extractBlock(src, startMarker, endMarker string) string {
|
|
startIdx := strings.Index(src, startMarker)
|
|
if startIdx == -1 {
|
|
return ""
|
|
}
|
|
endIdx := strings.Index(src[startIdx:], endMarker)
|
|
if endIdx == -1 {
|
|
return ""
|
|
}
|
|
return src[startIdx : startIdx+endIdx+len(endMarker)]
|
|
}
|
|
|
|
// sortedKeys returns sorted keys of a map
|
|
func sortedKeys(m map[string][]string) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|