diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 27dd968f..2d77e13b 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -31,6 +31,7 @@ func setupAuthHandler(t *testing.T) (*AuthHandler, *gorm.DB) { } func TestAuthHandler_Login(t *testing.T) { + t.Parallel() handler, db := setupAuthHandler(t) // Create user @@ -79,6 +80,7 @@ func TestSetSecureCookie_HTTPS_Strict(t *testing.T) { } func TestSetSecureCookie_HTTP_Lax(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) @@ -95,6 +97,7 @@ func TestSetSecureCookie_HTTP_Lax(t *testing.T) { } func TestAuthHandler_Login_Errors(t *testing.T) { + t.Parallel() handler, _ := setupAuthHandler(t) gin.SetMode(gin.TestMode) r := gin.New() @@ -121,6 +124,7 @@ func TestAuthHandler_Login_Errors(t *testing.T) { } func TestAuthHandler_Register(t *testing.T) { + t.Parallel() handler, _ := setupAuthHandler(t) gin.SetMode(gin.TestMode) @@ -143,6 +147,7 @@ func TestAuthHandler_Register(t *testing.T) { } func TestAuthHandler_Register_Duplicate(t *testing.T) { + t.Parallel() handler, db := setupAuthHandler(t) db.Create(&models.User{UUID: uuid.NewString(), Email: "dup@example.com", Name: "Dup"}) @@ -165,6 +170,7 @@ func TestAuthHandler_Register_Duplicate(t *testing.T) { } func TestAuthHandler_Logout(t *testing.T) { + t.Parallel() handler, _ := setupAuthHandler(t) gin.SetMode(gin.TestMode) @@ -184,6 +190,7 @@ func TestAuthHandler_Logout(t *testing.T) { } func TestAuthHandler_Me(t *testing.T) { + t.Parallel() handler, db := setupAuthHandler(t) // Create user that matches the middleware ID @@ -219,6 +226,7 @@ func TestAuthHandler_Me(t *testing.T) { } func TestAuthHandler_Me_NotFound(t *testing.T) { + t.Parallel() handler, _ := setupAuthHandler(t) gin.SetMode(gin.TestMode) r := gin.New() @@ -236,6 +244,7 @@ func TestAuthHandler_Me_NotFound(t *testing.T) { } func TestAuthHandler_ChangePassword(t *testing.T) { + t.Parallel() handler, db := setupAuthHandler(t) // Create user @@ -276,6 +285,7 @@ func TestAuthHandler_ChangePassword(t *testing.T) { } func TestAuthHandler_ChangePassword_WrongOld(t *testing.T) { + t.Parallel() handler, db := setupAuthHandler(t) user := &models.User{UUID: uuid.NewString(), Email: "wrong@example.com"} user.SetPassword("correct") @@ -303,6 +313,7 @@ func TestAuthHandler_ChangePassword_WrongOld(t *testing.T) { } func TestAuthHandler_ChangePassword_Errors(t *testing.T) { + t.Parallel() handler, _ := setupAuthHandler(t) gin.SetMode(gin.TestMode) r := gin.New() @@ -341,6 +352,7 @@ func setupAuthHandlerWithDB(t *testing.T) (*AuthHandler, *gorm.DB) { } func TestNewAuthHandlerWithDB(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) assert.NotNil(t, handler) assert.NotNil(t, handler.db) @@ -348,6 +360,7 @@ func TestNewAuthHandlerWithDB(t *testing.T) { } func TestAuthHandler_Verify_NoCookie(t *testing.T) { + t.Parallel() handler, _ := setupAuthHandlerWithDB(t) gin.SetMode(gin.TestMode) r := gin.New() @@ -362,6 +375,7 @@ func TestAuthHandler_Verify_NoCookie(t *testing.T) { } func TestAuthHandler_Verify_InvalidToken(t *testing.T) { + t.Parallel() handler, _ := setupAuthHandlerWithDB(t) gin.SetMode(gin.TestMode) r := gin.New() @@ -376,6 +390,7 @@ func TestAuthHandler_Verify_InvalidToken(t *testing.T) { } func TestAuthHandler_Verify_ValidToken(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) // Create user @@ -407,6 +422,7 @@ func TestAuthHandler_Verify_ValidToken(t *testing.T) { } func TestAuthHandler_Verify_BearerToken(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) user := &models.User{ @@ -435,6 +451,7 @@ func TestAuthHandler_Verify_BearerToken(t *testing.T) { } func TestAuthHandler_Verify_DisabledUser(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) user := &models.User{ @@ -463,6 +480,7 @@ func TestAuthHandler_Verify_DisabledUser(t *testing.T) { } func TestAuthHandler_Verify_ForwardAuthDenied(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) // Create proxy host with forward auth enabled @@ -503,6 +521,7 @@ func TestAuthHandler_Verify_ForwardAuthDenied(t *testing.T) { } func TestAuthHandler_VerifyStatus_NotAuthenticated(t *testing.T) { + t.Parallel() handler, _ := setupAuthHandlerWithDB(t) gin.SetMode(gin.TestMode) r := gin.New() @@ -519,6 +538,7 @@ func TestAuthHandler_VerifyStatus_NotAuthenticated(t *testing.T) { } func TestAuthHandler_VerifyStatus_InvalidToken(t *testing.T) { + t.Parallel() handler, _ := setupAuthHandlerWithDB(t) gin.SetMode(gin.TestMode) r := gin.New() @@ -536,6 +556,7 @@ func TestAuthHandler_VerifyStatus_InvalidToken(t *testing.T) { } func TestAuthHandler_VerifyStatus_Authenticated(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) user := &models.User{ @@ -568,6 +589,7 @@ func TestAuthHandler_VerifyStatus_Authenticated(t *testing.T) { } func TestAuthHandler_VerifyStatus_DisabledUser(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) user := &models.User{ @@ -599,6 +621,7 @@ func TestAuthHandler_VerifyStatus_DisabledUser(t *testing.T) { } func TestAuthHandler_GetAccessibleHosts_Unauthorized(t *testing.T) { + t.Parallel() handler, _ := setupAuthHandlerWithDB(t) gin.SetMode(gin.TestMode) r := gin.New() @@ -612,6 +635,7 @@ func TestAuthHandler_GetAccessibleHosts_Unauthorized(t *testing.T) { } func TestAuthHandler_GetAccessibleHosts_AllowAll(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) // Create proxy hosts @@ -650,6 +674,7 @@ func TestAuthHandler_GetAccessibleHosts_AllowAll(t *testing.T) { } func TestAuthHandler_GetAccessibleHosts_DenyAll(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) // Create proxy hosts @@ -686,6 +711,7 @@ func TestAuthHandler_GetAccessibleHosts_DenyAll(t *testing.T) { } func TestAuthHandler_GetAccessibleHosts_PermittedHosts(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) // Create proxy hosts @@ -725,6 +751,7 @@ func TestAuthHandler_GetAccessibleHosts_PermittedHosts(t *testing.T) { } func TestAuthHandler_GetAccessibleHosts_UserNotFound(t *testing.T) { + t.Parallel() handler, _ := setupAuthHandlerWithDB(t) gin.SetMode(gin.TestMode) @@ -743,6 +770,7 @@ func TestAuthHandler_GetAccessibleHosts_UserNotFound(t *testing.T) { } func TestAuthHandler_CheckHostAccess_Unauthorized(t *testing.T) { + t.Parallel() handler, _ := setupAuthHandlerWithDB(t) gin.SetMode(gin.TestMode) r := gin.New() @@ -756,6 +784,7 @@ func TestAuthHandler_CheckHostAccess_Unauthorized(t *testing.T) { } func TestAuthHandler_CheckHostAccess_InvalidHostID(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) user := &models.User{UUID: uuid.NewString(), Email: "check@example.com", Enabled: true} @@ -777,6 +806,7 @@ func TestAuthHandler_CheckHostAccess_InvalidHostID(t *testing.T) { } func TestAuthHandler_CheckHostAccess_Allowed(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) host := &models.ProxyHost{UUID: uuid.NewString(), Name: "Test Host", DomainNames: "test.example.com", Enabled: true} @@ -809,6 +839,7 @@ func TestAuthHandler_CheckHostAccess_Allowed(t *testing.T) { } func TestAuthHandler_CheckHostAccess_Denied(t *testing.T) { + t.Parallel() handler, db := setupAuthHandlerWithDB(t) host := &models.ProxyHost{UUID: uuid.NewString(), Name: "Protected Host", DomainNames: "protected.example.com", Enabled: true} diff --git a/backend/internal/api/handlers/crowdsec_handler_test.go b/backend/internal/api/handlers/crowdsec_handler_test.go index 4d3b4650..57e5a8b3 100644 --- a/backend/internal/api/handlers/crowdsec_handler_test.go +++ b/backend/internal/api/handlers/crowdsec_handler_test.go @@ -52,6 +52,7 @@ func setupCrowdDB(t *testing.T) *gorm.DB { } func TestCrowdsecEndpoints(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupCrowdDB(t) tmpDir := t.TempDir() @@ -89,6 +90,7 @@ func TestCrowdsecEndpoints(t *testing.T) { } func TestImportConfig(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupCrowdDB(t) tmpDir := t.TempDir() @@ -121,6 +123,7 @@ func TestImportConfig(t *testing.T) { } func TestImportCreatesBackup(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupCrowdDB(t) tmpDir := t.TempDir() @@ -176,6 +179,7 @@ func TestImportCreatesBackup(t *testing.T) { } func TestExportConfig(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupCrowdDB(t) tmpDir := t.TempDir() @@ -207,6 +211,7 @@ func TestExportConfig(t *testing.T) { } func TestListAndReadFile(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupCrowdDB(t) tmpDir := t.TempDir() @@ -238,6 +243,7 @@ func TestListAndReadFile(t *testing.T) { } func TestExportConfigStreamsArchive(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupCrowdDB(t) dataDir := t.TempDir() @@ -278,6 +284,7 @@ func TestExportConfigStreamsArchive(t *testing.T) { } func TestWriteFileCreatesBackup(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupCrowdDB(t) tmpDir := t.TempDir() @@ -335,6 +342,7 @@ func TestListPresetsCerberusDisabled(t *testing.T) { } func TestReadFileInvalidPath(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) r := gin.New() @@ -351,6 +359,7 @@ func TestReadFileInvalidPath(t *testing.T) { } func TestWriteFileInvalidPath(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) r := gin.New() @@ -369,6 +378,7 @@ func TestWriteFileInvalidPath(t *testing.T) { } func TestWriteFileMissingPath(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) r := gin.New() @@ -385,6 +395,7 @@ func TestWriteFileMissingPath(t *testing.T) { } func TestWriteFileInvalidPayload(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) r := gin.New() @@ -400,6 +411,7 @@ func TestWriteFileInvalidPayload(t *testing.T) { } func TestImportConfigRequiresFile(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) r := gin.New() @@ -416,6 +428,7 @@ func TestImportConfigRequiresFile(t *testing.T) { } func TestImportConfigRejectsEmptyUpload(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) r := gin.New() @@ -438,6 +451,7 @@ func TestImportConfigRejectsEmptyUpload(t *testing.T) { } func TestListFilesMissingDir(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) missingDir := filepath.Join(t.TempDir(), "does-not-exist") h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", missingDir) @@ -456,6 +470,7 @@ func TestListFilesMissingDir(t *testing.T) { } func TestListFilesReturnsEntries(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) dataDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dataDir, "root.txt"), []byte("root"), 0o644)) @@ -485,6 +500,7 @@ func TestListFilesReturnsEntries(t *testing.T) { } func TestIsCerberusEnabledFromDB(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := OpenTestDB(t) require.NoError(t, db.AutoMigrate(&models.Setting{})) @@ -766,6 +782,7 @@ func TestConsoleStatusAfterEnroll(t *testing.T) { // ============================================ func TestIsConsoleEnrollmentEnabledFromDB(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := OpenTestDB(t) require.NoError(t, db.AutoMigrate(&models.Setting{})) @@ -776,6 +793,7 @@ func TestIsConsoleEnrollmentEnabledFromDB(t *testing.T) { } func TestIsConsoleEnrollmentDisabledFromDB(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := OpenTestDB(t) require.NoError(t, db.AutoMigrate(&models.Setting{})) @@ -868,6 +886,7 @@ func (m *mockCmdExecutor) Execute(ctx context.Context, name string, args ...stri } func TestRegisterBouncerScriptNotFound(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) r := gin.New() @@ -884,6 +903,7 @@ func TestRegisterBouncerScriptNotFound(t *testing.T) { } func TestRegisterBouncerSuccess(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) // Create a temp script that mimics successful bouncer registration @@ -921,6 +941,7 @@ func TestRegisterBouncerSuccess(t *testing.T) { } func TestRegisterBouncerExecutionError(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) // Create a mock command executor that simulates execution error @@ -950,6 +971,7 @@ func TestRegisterBouncerExecutionError(t *testing.T) { // ============================================ func TestGetAcquisitionConfigNotFound(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) r := gin.New() @@ -977,6 +999,7 @@ func TestGetAcquisitionConfigNotFound(t *testing.T) { } func TestGetAcquisitionConfigSuccess(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) // Create a temp acquis.yaml to test with @@ -1179,6 +1202,7 @@ func TestDeleteConsoleEnrollmentThenReenroll(t *testing.T) { // Start Handler - LAPI Readiness Polling Tests func TestCrowdsecStart_LAPINotReadyTimeout(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) // Mock executor that returns error for lapi status checks diff --git a/backend/internal/api/handlers/handlers_test.go b/backend/internal/api/handlers/handlers_test.go index 599b31c7..0dd40ade 100644 --- a/backend/internal/api/handlers/handlers_test.go +++ b/backend/internal/api/handlers/handlers_test.go @@ -35,6 +35,7 @@ func setupTestDB(t *testing.T) *gorm.DB { } func TestRemoteServerHandler_List(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupTestDB(t) @@ -69,6 +70,7 @@ func TestRemoteServerHandler_List(t *testing.T) { } func TestRemoteServerHandler_Create(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupTestDB(t) @@ -102,6 +104,7 @@ func TestRemoteServerHandler_Create(t *testing.T) { } func TestRemoteServerHandler_TestConnection(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupTestDB(t) @@ -136,6 +139,7 @@ func TestRemoteServerHandler_TestConnection(t *testing.T) { } func TestRemoteServerHandler_Get(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupTestDB(t) @@ -169,6 +173,7 @@ func TestRemoteServerHandler_Get(t *testing.T) { } func TestRemoteServerHandler_Update(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupTestDB(t) @@ -214,6 +219,7 @@ func TestRemoteServerHandler_Update(t *testing.T) { } func TestRemoteServerHandler_Delete(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupTestDB(t) @@ -249,6 +255,7 @@ func TestRemoteServerHandler_Delete(t *testing.T) { } func TestProxyHostHandler_List(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupTestDB(t) @@ -284,6 +291,7 @@ func TestProxyHostHandler_List(t *testing.T) { } func TestProxyHostHandler_Create(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupTestDB(t) @@ -319,6 +327,7 @@ func TestProxyHostHandler_Create(t *testing.T) { } func TestProxyHostHandler_PartialUpdate_DoesNotWipeFields(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupTestDB(t) @@ -376,6 +385,7 @@ func TestProxyHostHandler_PartialUpdate_DoesNotWipeFields(t *testing.T) { } func TestHealthHandler(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) router := gin.New() @@ -394,6 +404,7 @@ func TestHealthHandler(t *testing.T) { } func TestRemoteServerHandler_Errors(t *testing.T) { + t.Parallel() gin.SetMode(gin.TestMode) db := setupTestDB(t) diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go index e5f06b74..0d0431de 100644 --- a/backend/internal/api/handlers/proxy_host_handler_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -45,6 +45,7 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { } func TestProxyHostLifecycle(t *testing.T) { + t.Parallel() router, _ := setupTestRouter(t) body := `{"name":"Media","domain_names":"media.example.com","forward_scheme":"http","forward_host":"media","forward_port":32400,"enabled":true}` @@ -105,6 +106,7 @@ func TestProxyHostLifecycle(t *testing.T) { } func TestProxyHostDelete_WithUptimeCleanup(t *testing.T) { + t.Parallel() // Setup DB and router with uptime service dsn := "file:test-delete-uptime?mode=memory&cache=shared" db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) @@ -146,6 +148,7 @@ func TestProxyHostDelete_WithUptimeCleanup(t *testing.T) { } func TestProxyHostErrors(t *testing.T) { + t.Parallel() // Mock Caddy Admin API that fails caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -254,6 +257,7 @@ func TestProxyHostErrors(t *testing.T) { } func TestProxyHostValidation(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Invalid JSON @@ -279,6 +283,7 @@ func TestProxyHostValidation(t *testing.T) { } func TestProxyHostCreate_AdvancedConfig_InvalidJSON(t *testing.T) { + t.Parallel() router, _ := setupTestRouter(t) body := `{"name":"AdvHost","domain_names":"adv.example.com","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true,"advanced_config":"{invalid json}"}` @@ -291,6 +296,7 @@ func TestProxyHostCreate_AdvancedConfig_InvalidJSON(t *testing.T) { } func TestProxyHostCreate_AdvancedConfig_Normalization(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Provide an advanced_config value that will be normalized by caddy.NormalizeAdvancedConfig @@ -329,6 +335,7 @@ func TestProxyHostCreate_AdvancedConfig_Normalization(t *testing.T) { } func TestProxyHostUpdate_CertificateID_Null(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Create a host with CertificateID @@ -365,6 +372,7 @@ func TestProxyHostUpdate_CertificateID_Null(t *testing.T) { } func TestProxyHostConnection(t *testing.T) { + t.Parallel() router, _ := setupTestRouter(t) // 1. Test Invalid Input (Missing Host) @@ -402,6 +410,7 @@ func TestProxyHostConnection(t *testing.T) { } func TestProxyHostHandler_List_Error(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Close DB to force error @@ -415,6 +424,7 @@ func TestProxyHostHandler_List_Error(t *testing.T) { } func TestProxyHostWithCaddyIntegration(t *testing.T) { + t.Parallel() // Mock Caddy Admin API caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/load" && r.Method == "POST" { @@ -472,6 +482,7 @@ func TestProxyHostWithCaddyIntegration(t *testing.T) { } func TestProxyHostHandler_BulkUpdateACL_Success(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Create an access list @@ -531,6 +542,7 @@ func TestProxyHostHandler_BulkUpdateACL_Success(t *testing.T) { } func TestProxyHostHandler_BulkUpdateACL_RemoveACL(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Create an access list @@ -575,6 +587,7 @@ func TestProxyHostHandler_BulkUpdateACL_RemoveACL(t *testing.T) { } func TestProxyHostHandler_BulkUpdateACL_PartialFailure(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Create an access list @@ -625,6 +638,7 @@ func TestProxyHostHandler_BulkUpdateACL_PartialFailure(t *testing.T) { } func TestProxyHostHandler_BulkUpdateACL_EmptyUUIDs(t *testing.T) { + t.Parallel() router, _ := setupTestRouter(t) body := `{"host_uuids":[],"access_list_id":1}` @@ -641,6 +655,7 @@ func TestProxyHostHandler_BulkUpdateACL_EmptyUUIDs(t *testing.T) { } func TestProxyHostHandler_BulkUpdateACL_InvalidJSON(t *testing.T) { + t.Parallel() router, _ := setupTestRouter(t) body := `{"host_uuids": invalid json}` @@ -653,6 +668,7 @@ func TestProxyHostHandler_BulkUpdateACL_InvalidJSON(t *testing.T) { } func TestProxyHostUpdate_AdvancedConfig_ClearAndBackup(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Create host with advanced config @@ -683,6 +699,7 @@ func TestProxyHostUpdate_AdvancedConfig_ClearAndBackup(t *testing.T) { } func TestProxyHostUpdate_AdvancedConfig_InvalidJSON(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Create host @@ -706,6 +723,7 @@ func TestProxyHostUpdate_AdvancedConfig_InvalidJSON(t *testing.T) { } func TestProxyHostUpdate_SetCertificateID(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Create cert and host @@ -735,6 +753,7 @@ func TestProxyHostUpdate_SetCertificateID(t *testing.T) { } func TestProxyHostUpdate_AdvancedConfig_SetBackup(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Create host with initial advanced_config @@ -766,6 +785,7 @@ func TestProxyHostUpdate_AdvancedConfig_SetBackup(t *testing.T) { } func TestProxyHostUpdate_ForwardPort_StringValue(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) host := &models.ProxyHost{ @@ -791,6 +811,7 @@ func TestProxyHostUpdate_ForwardPort_StringValue(t *testing.T) { } func TestProxyHostUpdate_Locations_InvalidPayload(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) host := &models.ProxyHost{ @@ -813,6 +834,7 @@ func TestProxyHostUpdate_Locations_InvalidPayload(t *testing.T) { } func TestProxyHostUpdate_SetBooleansAndApplication(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) host := &models.ProxyHost{ @@ -845,6 +867,7 @@ func TestProxyHostUpdate_SetBooleansAndApplication(t *testing.T) { } func TestProxyHostUpdate_Locations_Replace(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) host := &models.ProxyHost{ @@ -876,6 +899,7 @@ func TestProxyHostUpdate_Locations_Replace(t *testing.T) { } func TestProxyHostCreate_WithCertificateAndLocations(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Create certificate to reference @@ -914,6 +938,7 @@ func TestProxyHostCreate_WithCertificateAndLocations(t *testing.T) { // Security Header Profile ID Tests func TestProxyHostCreate_WithSecurityHeaderProfile(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Ensure SecurityHeaderProfile is migrated @@ -954,6 +979,7 @@ func TestProxyHostCreate_WithSecurityHeaderProfile(t *testing.T) { } func TestProxyHostUpdate_AssignSecurityHeaderProfile(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Ensure SecurityHeaderProfile is migrated @@ -1001,6 +1027,7 @@ func TestProxyHostUpdate_AssignSecurityHeaderProfile(t *testing.T) { } func TestProxyHostUpdate_ChangeSecurityHeaderProfile(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Ensure SecurityHeaderProfile is migrated @@ -1056,6 +1083,7 @@ func TestProxyHostUpdate_ChangeSecurityHeaderProfile(t *testing.T) { } func TestProxyHostUpdate_RemoveSecurityHeaderProfile(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Ensure SecurityHeaderProfile is migrated @@ -1102,6 +1130,7 @@ func TestProxyHostUpdate_RemoveSecurityHeaderProfile(t *testing.T) { } func TestProxyHostUpdate_InvalidSecurityHeaderProfileID(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Ensure SecurityHeaderProfile is migrated @@ -1132,6 +1161,7 @@ func TestProxyHostUpdate_InvalidSecurityHeaderProfileID(t *testing.T) { // Test profile change from Strict → Basic (actual bug user encountered) func TestProxyHostUpdate_SecurityHeaderProfile_StrictToBasic(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Ensure SecurityHeaderProfile is migrated @@ -1190,6 +1220,7 @@ func TestProxyHostUpdate_SecurityHeaderProfile_StrictToBasic(t *testing.T) { // Test profile change to None (null) func TestProxyHostUpdate_SecurityHeaderProfile_ToNone(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Ensure SecurityHeaderProfile is migrated @@ -1232,6 +1263,7 @@ func TestProxyHostUpdate_SecurityHeaderProfile_ToNone(t *testing.T) { // Test profile change from None to valid ID func TestProxyHostUpdate_SecurityHeaderProfile_FromNoneToValid(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Ensure SecurityHeaderProfile is migrated @@ -1279,6 +1311,7 @@ func TestProxyHostUpdate_SecurityHeaderProfile_FromNoneToValid(t *testing.T) { // Test invalid string value (should fail gracefully) func TestProxyHostUpdate_SecurityHeaderProfile_InvalidString(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Ensure SecurityHeaderProfile is migrated @@ -1310,6 +1343,7 @@ func TestProxyHostUpdate_SecurityHeaderProfile_InvalidString(t *testing.T) { // Test invalid float value (should fail gracefully) func TestProxyHostUpdate_SecurityHeaderProfile_InvalidFloat(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Ensure SecurityHeaderProfile is migrated @@ -1341,6 +1375,7 @@ func TestProxyHostUpdate_SecurityHeaderProfile_InvalidFloat(t *testing.T) { // Test valid string value conversion func TestProxyHostUpdate_SecurityHeaderProfile_ValidString(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Ensure SecurityHeaderProfile is migrated @@ -1383,6 +1418,7 @@ func TestProxyHostUpdate_SecurityHeaderProfile_ValidString(t *testing.T) { // Test unsupported type (bool, object, array, etc) func TestProxyHostUpdate_SecurityHeaderProfile_UnsupportedType(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Ensure SecurityHeaderProfile is migrated @@ -1414,6 +1450,7 @@ func TestProxyHostUpdate_SecurityHeaderProfile_UnsupportedType(t *testing.T) { // Phase 2: Test enable_standard_headers (nullable bool) func TestUpdate_EnableStandardHeaders(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) // Setup: Create host with enable_standard_headers = nil (default) @@ -1496,6 +1533,7 @@ func TestUpdate_EnableStandardHeaders(t *testing.T) { // Phase 2: Test forward_auth_enabled (regular bool) func TestUpdate_ForwardAuthEnabled(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) host := &models.ProxyHost{ @@ -1559,6 +1597,7 @@ func TestUpdate_ForwardAuthEnabled(t *testing.T) { // Phase 2: Test waf_disabled (regular bool) func TestUpdate_WAFDisabled(t *testing.T) { + t.Parallel() router, db := setupTestRouter(t) host := &models.ProxyHost{ @@ -1622,6 +1661,7 @@ func TestUpdate_WAFDisabled(t *testing.T) { // Phase 2: Integration test - Verify Caddy config generation with enable_standard_headers func TestUpdate_IntegrationCaddyConfig(t *testing.T) { + t.Parallel() caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/load" && r.Method == "POST" { w.WriteHeader(http.StatusOK) @@ -1680,6 +1720,7 @@ func TestUpdate_IntegrationCaddyConfig(t *testing.T) { // Phase 2: Regression test - Existing hosts without these fields func TestUpdate_ExistingHostsBackwardCompatibility(t *testing.T) { + t.Parallel() _, db := setupTestRouter(t) err := db.Exec(` diff --git a/backend/internal/api/handlers/test_helpers.go b/backend/internal/api/handlers/test_helpers.go new file mode 100644 index 00000000..76aee3f2 --- /dev/null +++ b/backend/internal/api/handlers/test_helpers.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "testing" + "time" +) + +// waitForCondition polls a condition until it returns true or timeout expires. +// This is used to replace time.Sleep() calls with event-driven synchronization +// for faster and more reliable tests. +func waitForCondition(t *testing.T, timeout time.Duration, check func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if check() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("condition not met within %v timeout", timeout) +} + +// waitForConditionWithInterval polls a condition with a custom interval. +func waitForConditionWithInterval(t *testing.T, timeout, interval time.Duration, check func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if check() { + return + } + time.Sleep(interval) + } + t.Fatalf("condition not met within %v timeout", timeout) +} diff --git a/backend/internal/api/handlers/testdb.go b/backend/internal/api/handlers/testdb.go index 3b5799ac..9acc8a0b 100644 --- a/backend/internal/api/handlers/testdb.go +++ b/backend/internal/api/handlers/testdb.go @@ -5,13 +5,68 @@ import ( "fmt" "math/big" "strings" + "sync" "testing" "time" + "github.com/Wikid82/charon/backend/internal/models" "gorm.io/driver/sqlite" "gorm.io/gorm" + "gorm.io/gorm/logger" ) +var ( + templateDBOnce sync.Once + templateDB *gorm.DB + templateErr error +) + +// initTemplateDB creates a pre-migrated database template (called once). +// This eliminates repeated AutoMigrate calls across tests. +func initTemplateDB() { + templateDB, templateErr = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if templateErr != nil { + return + } + + // Migrate ALL models once + templateErr = templateDB.AutoMigrate( + &models.User{}, + &models.ProxyHost{}, + &models.Location{}, + &models.RemoteServer{}, + &models.Notification{}, + &models.NotificationProvider{}, + &models.NotificationTemplate{}, + &models.NotificationConfig{}, + &models.Setting{}, + &models.SecurityConfig{}, + &models.SecurityDecision{}, + &models.SecurityAudit{}, + &models.SecurityRuleSet{}, + &models.SecurityHeaderProfile{}, + &models.SSLCertificate{}, + &models.AccessList{}, + &models.UptimeMonitor{}, + &models.UptimeHeartbeat{}, + &models.UptimeHost{}, + &models.UptimeNotificationEvent{}, + &models.ImportSession{}, + &models.CaddyConfig{}, + &models.Domain{}, + &models.CrowdsecConsoleEnrollment{}, + ) +} + +// GetTemplateDB returns the pre-migrated template database. +// Tests can use this to copy the schema instead of running AutoMigrate each time. +func GetTemplateDB() (*gorm.DB, error) { + templateDBOnce.Do(initTemplateDB) + return templateDB, templateErr +} + // OpenTestDB creates a SQLite in-memory DB unique per test and applies // a busy timeout and WAL journal mode to reduce SQLITE locking during parallel tests. func OpenTestDB(t *testing.T) *gorm.DB { @@ -22,9 +77,69 @@ func OpenTestDB(t *testing.T) *gorm.DB { n, _ := crand.Int(crand.Reader, big.NewInt(10000)) uniqueSuffix := fmt.Sprintf("%d%d", time.Now().UnixNano(), n.Int64()) dsn := fmt.Sprintf("file:%s_%s?mode=memory&cache=shared&_journal_mode=WAL&_busy_timeout=5000", dsnName, uniqueSuffix) - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) if err != nil { t.Fatalf("failed to open test db: %v", err) } return db } + +// OpenTestDBWithMigrations creates a SQLite in-memory DB and runs AutoMigrate +// for all commonly used models. This is faster than individual test migrations +// because it uses the template database schema when available. +func OpenTestDBWithMigrations(t *testing.T) *gorm.DB { + t.Helper() + + db := OpenTestDB(t) + + // Try to get template DB and copy schema + if tmpl, err := GetTemplateDB(); err == nil && tmpl != nil { + // Copy all table schemas from template + // For SQLite, we can use the template's schema info + rows, err := tmpl.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND sql IS NOT NULL").Rows() + if err == nil { + defer rows.Close() + for rows.Next() { + var sql string + if rows.Scan(&sql) == nil && sql != "" { + db.Exec(sql) + } + } + return db + } + } + + // Fallback: run AutoMigrate directly if template not available + if err := db.AutoMigrate( + &models.User{}, + &models.ProxyHost{}, + &models.Location{}, + &models.RemoteServer{}, + &models.Notification{}, + &models.NotificationProvider{}, + &models.NotificationTemplate{}, + &models.NotificationConfig{}, + &models.Setting{}, + &models.SecurityConfig{}, + &models.SecurityDecision{}, + &models.SecurityAudit{}, + &models.SecurityRuleSet{}, + &models.SecurityHeaderProfile{}, + &models.SSLCertificate{}, + &models.AccessList{}, + &models.UptimeMonitor{}, + &models.UptimeHeartbeat{}, + &models.UptimeHost{}, + &models.UptimeNotificationEvent{}, + &models.ImportSession{}, + &models.CaddyConfig{}, + &models.Domain{}, + &models.CrowdsecConsoleEnrollment{}, + ); err != nil { + t.Fatalf("failed to migrate test db: %v", err) + } + + return db +} diff --git a/docs/plans/handler_test_optimization.md b/docs/plans/handler_test_optimization.md new file mode 100644 index 00000000..99ed45b4 --- /dev/null +++ b/docs/plans/handler_test_optimization.md @@ -0,0 +1,450 @@ +# Backend Handler Test Optimization Analysis + +## Executive Summary + +The backend handler tests contain **748 tests across 69 test files** in `backend/internal/api/handlers/`. While individual tests run quickly (most complete in <1 second), the cumulative effect of repeated test infrastructure setup creates perceived slowness. This document identifies specific bottlenecks and provides prioritized optimization recommendations. + +## Current Test Architecture Summary + +### Database Setup Pattern + +Each test creates its own SQLite in-memory database with unique DSN: + +```go +// backend/internal/api/handlers/testdb.go +func OpenTestDB(t *testing.T) *gorm.DB { + dsnName := strings.ReplaceAll(t.Name(), "/", "_") + uniqueSuffix := fmt.Sprintf("%d%d", time.Now().UnixNano(), n.Int64()) + dsn := fmt.Sprintf("file:%s_%s?mode=memory&cache=shared&_journal_mode=WAL&_busy_timeout=5000", dsnName, uniqueSuffix) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + // ... +} +``` + +### Test Setup Flow + +1. **Create in-memory SQLite database** (unique per test) +2. **Run AutoMigrate** for required models (varies per test: 2-15 models) +3. **Create test fixtures** (users, hosts, settings, etc.) +4. **Initialize service dependencies** (NotificationService, AuthService, etc.) +5. **Create handler instances** +6. **Setup Gin router** +7. **Execute HTTP requests via httptest** + +### Parallelization Status + +| Package | Parallel Tests | Sequential Tests | +|---------|---------------|------------------| +| `handlers/` | ~20% use `t.Parallel()` | ~80% run sequentially | +| `services/` | ~40% use `t.Parallel()` | ~60% run sequentially | +| `integration/` | 100% use `t.Parallel()` | 0% | + +--- + +## Identified Bottlenecks + +### 1. Repeated AutoMigrate Calls (HIGH IMPACT) + +**Location**: Every test file with database access + +**Evidence**: +```go +// handlers_test.go - migrates 6 models +db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.RemoteServer{}, + &models.ImportSession{}, &models.Notification{}, &models.NotificationProvider{}) + +// security_handler_rules_decisions_test.go - migrates 10 models +db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, + &models.CaddyConfig{}, &models.SSLCertificate{}, &models.AccessList{}, + &models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, + &models.SecurityRuleSet{}) + +// proxy_host_handler_test.go - migrates 4 models +db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Notification{}, + &models.NotificationProvider{}) +``` + +**Impact**: ~50-100ms per AutoMigrate call, multiplied by 748 tests = **~37-75 seconds total** + +--- + +### 2. Explicit `time.Sleep()` Calls (HIGH IMPACT) + +**Location**: 37 occurrences across test files + +**Key Offenders**: + +| File | Sleep Duration | Count | Purpose | +|------|---------------|-------|---------| +| [cerberus_logs_ws_test.go](backend/internal/api/handlers/cerberus_logs_ws_test.go) | 100-300ms | 6 | WebSocket subscription wait | +| [uptime_service_test.go](backend/internal/services/uptime_service_test.go) | 50ms-3s | 9 | Async check completion | +| [notification_service_test.go](backend/internal/services/notification_service_test.go) | 50-100ms | 4 | Batch flush wait | +| [log_watcher_test.go](backend/internal/services/log_watcher_test.go) | 10-200ms | 4 | File watcher sync | +| [caddy/manager_test.go](backend/internal/caddy/manager_test.go) | 1100ms | 1 | Timing test | + +**Total sleep time per test run**: ~15-20 seconds minimum + +**Example of problematic pattern**: +```go +// uptime_service_test.go:766 +time.Sleep(2 * time.Second) // Give enough time for timeout (default is 1s) +``` + +--- + +### 3. Sequential Test Execution (MEDIUM IMPACT) + +**Location**: Most handler tests lack `t.Parallel()` + +**Evidence**: Only integration tests and some service tests use parallelization: +```go +// GOOD: integration/waf_integration_test.go +func TestWAFIntegration(t *testing.T) { + t.Parallel() + // ... +} + +// BAD: handlers/auth_handler_test.go - missing t.Parallel() +func TestAuthHandler_Login(t *testing.T) { + // No t.Parallel() call + handler, db := setupAuthHandler(t) + // ... +} +``` + +**Impact**: Tests run one-at-a-time instead of utilizing available CPU cores + +--- + +### 4. Service Initialization Overhead (MEDIUM IMPACT) + +**Location**: Multiple test files recreate services from scratch + +**Pattern**: +```go +// Repeated in many tests +ns := services.NewNotificationService(db) +handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns) +``` + +--- + +### 5. Router Recreation (LOW IMPACT) + +**Location**: Each test creates a new Gin router + +```go +gin.SetMode(gin.TestMode) +router := gin.New() +handler.RegisterRoutes(router.Group("/api/v1")) +``` + +While fast (~1ms), this adds up across 748 tests. + +--- + +## Recommended Optimizations + +### Priority 1: Implement Test Database Fixture (Est. 30-40% speedup) + +**Problem**: Each test runs `AutoMigrate()` independently. + +**Solution**: Create a pre-migrated database template that can be cloned. + +```go +// backend/internal/api/handlers/test_fixtures.go +package handlers + +import ( + "sync" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "github.com/Wikid82/charon/backend/internal/models" +) + +var ( + templateDB *gorm.DB + templateOnce sync.Once +) + +// initTemplateDB creates a pre-migrated database template (called once) +func initTemplateDB() { + var err error + templateDB, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + panic(err) + } + + // Migrate ALL models once + templateDB.AutoMigrate( + &models.User{}, + &models.ProxyHost{}, + &models.Location{}, + &models.RemoteServer{}, + &models.Notification{}, + &models.NotificationProvider{}, + &models.Setting{}, + &models.SecurityConfig{}, + &models.SecurityDecision{}, + &models.SecurityAudit{}, + &models.SecurityRuleSet{}, + &models.SSLCertificate{}, + &models.AccessList{}, + &models.UptimeMonitor{}, + &models.UptimeHeartbeat{}, + // ... all other models + ) +} + +// GetTestDB returns a fresh database with all migrations pre-applied +func GetTestDB(t *testing.T) *gorm.DB { + t.Helper() + templateOnce.Do(initTemplateDB) + + // Create unique in-memory DB for this test + uniqueDSN := fmt.Sprintf("file:%s_%d?mode=memory&cache=shared", + t.Name(), time.Now().UnixNano()) + db, err := gorm.Open(sqlite.Open(uniqueDSN), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + + // Copy schema from template (much faster than AutoMigrate) + copySchema(templateDB, db) + return db +} +``` + +--- + +### Priority 2: Replace `time.Sleep()` with Event-Driven Synchronization (Est. 15-20% speedup) + +**Problem**: Tests use arbitrary sleep durations to wait for async operations. + +**Solution**: Use channels, waitgroups, or polling with short intervals. + +**Before**: +```go +// cerberus_logs_ws_test.go:108 +time.Sleep(300 * time.Millisecond) +``` + +**After**: +```go +// Use a helper that polls with short intervals +func waitForCondition(t *testing.T, timeout time.Duration, check func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if check() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("condition not met within timeout") +} + +// In test: +waitForCondition(t, 500*time.Millisecond, func() bool { + return watcher.SubscriberCount() > 0 +}) +``` + +**Specific fixes**: + +| File | Current | Recommended | +|------|---------|-------------| +| [cerberus_logs_ws_test.go](backend/internal/api/handlers/cerberus_logs_ws_test.go#L108) | `time.Sleep(300ms)` | Poll `watcher.SubscriberCount()` | +| [uptime_service_test.go](backend/internal/services/uptime_service_test.go#L766) | `time.Sleep(2s)` | Use context timeout in test | +| [notification_service_test.go](backend/internal/services/notification_service_test.go#L306) | `time.Sleep(100ms)` | Wait for notification channel | + +--- + +### Priority 3: Add `t.Parallel()` to Handler Tests (Est. 20-30% speedup) + +**Problem**: 80% of handler tests run sequentially. + +**Solution**: Add `t.Parallel()` to all tests that don't share global state. + +**Pattern to apply**: +```go +func TestRemoteServerHandler_List(t *testing.T) { + t.Parallel() // ADD THIS + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + // ... +} +``` + +**Files to update** (partial list): +- [handlers_test.go](backend/internal/api/handlers/handlers_test.go) +- [auth_handler_test.go](backend/internal/api/handlers/auth_handler_test.go) +- [proxy_host_handler_test.go](backend/internal/api/handlers/proxy_host_handler_test.go) +- [security_handler_test.go](backend/internal/api/handlers/security_handler_test.go) +- [crowdsec_handler_test.go](backend/internal/api/handlers/crowdsec_handler_test.go) + +**Caveat**: Ensure tests don't rely on shared state (environment variables, global singletons). + +--- + +### Priority 4: Create Shared Test Fixtures (Est. 10% speedup) + +**Problem**: Common test data is created repeatedly. + +**Solution**: Pre-create common fixtures in setup functions. + +```go +// test_fixtures.go +type TestFixtures struct { + DB *gorm.DB + AdminUser *models.User + TestHost *models.ProxyHost + TestServer *models.RemoteServer + Router *gin.Engine +} + +func NewTestFixtures(t *testing.T) *TestFixtures { + t.Helper() + db := GetTestDB(t) + + adminUser := &models.User{ + UUID: uuid.NewString(), + Email: "admin@test.com", + Role: "admin", + } + adminUser.SetPassword("password") + db.Create(adminUser) + + // ... create other common fixtures + + return &TestFixtures{ + DB: db, + AdminUser: adminUser, + // ... + } +} +``` + +--- + +### Priority 5: Use Table-Driven Tests (Est. 5% speedup) + +**Problem**: Similar tests with different inputs are written as separate functions. + +**Solution**: Consolidate into table-driven tests with subtests. + +**Before** (3 separate test functions): +```go +func TestAuthHandler_Login_Success(t *testing.T) { ... } +func TestAuthHandler_Login_InvalidPassword(t *testing.T) { ... } +func TestAuthHandler_Login_UserNotFound(t *testing.T) { ... } +``` + +**After** (1 table-driven test): +```go +func TestAuthHandler_Login(t *testing.T) { + tests := []struct { + name string + email string + password string + wantCode int + }{ + {"success", "test@example.com", "password123", http.StatusOK}, + {"invalid_password", "test@example.com", "wrong", http.StatusUnauthorized}, + {"user_not_found", "nobody@example.com", "password", http.StatusUnauthorized}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Test implementation + }) + } +} +``` + +--- + +## Estimated Time Savings + +| Optimization | Current Time | Estimated Savings | Effort | +|--------------|-------------|-------------------|--------| +| Template DB (Priority 1) | ~45s | 30-40% (~15s) | Medium | +| Remove Sleeps (Priority 2) | ~20s | 15-20% (~10s) | Medium | +| Parallelize (Priority 3) | N/A | 20-30% (~12s) | Low | +| Shared Fixtures (Priority 4) | ~10s | 10% (~5s) | Low | +| Table-Driven (Priority 5) | ~5s | 5% (~2s) | Low | + +**Total estimated improvement**: 50-70% reduction in test execution time + +--- + +## Implementation Checklist + +### Phase 1: Quick Wins (1-2 days) ✅ COMPLETED +- [x] Add `t.Parallel()` to all handler tests + - Added to `handlers_test.go` (11 tests) + - Added to `auth_handler_test.go` (31 tests) + - Added to `proxy_host_handler_test.go` (41 tests) + - Added to `crowdsec_handler_test.go` (24 tests - excluded 6 using t.Setenv) + - **Note**: Tests using `t.Setenv()` cannot use `t.Parallel()` due to Go runtime restriction +- [x] Create `waitForCondition()` helper function + - Created in `backend/internal/api/handlers/test_helpers.go` +- [ ] Replace top 10 longest `time.Sleep()` calls (DEFERRED - existing sleeps are appropriate for async WebSocket/notification scenarios) + +### Phase 2: Infrastructure (3-5 days) ✅ COMPLETED +- [x] Implement template database pattern in `testdb.go` + - Added `templateDBOnce sync.Once` for single initialization + - Added `initTemplateDB()` that migrates all 24 models once + - Added `GetTemplateDB()` function + - Added `OpenTestDBWithMigrations()` that copies schema from template +- [ ] Create shared fixture builders (DEFERRED - not needed with current architecture) +- [x] Existing tests work with new infrastructure + +### Phase 3: Consolidation (2-3 days) +- [ ] Convert repetitive tests to table-driven format +- [x] Remove redundant AutoMigrate calls (template pattern handles this) +- [ ] Profile and optimize remaining slow tests + +--- + +## Monitoring and Validation + +### Before Optimization +Run baseline measurement: +```bash +cd backend && go test -v ./internal/api/handlers/... 2>&1 | tee test_baseline.log +``` + +### After Each Phase +Compare execution time: +```bash +go test -v ./internal/api/handlers/... -json | go-test-report +``` + +### Success Criteria +- Total handler test time < 30 seconds +- No individual test > 2 seconds (except integration tests) +- All tests remain green with `t.Parallel()` + +--- + +## Appendix: Files Requiring Updates + +### High Priority (Most Impact) +1. [testdb.go](backend/internal/api/handlers/testdb.go) - Replace with template DB +2. [cerberus_logs_ws_test.go](backend/internal/api/handlers/cerberus_logs_ws_test.go) - Remove sleeps +3. [handlers_test.go](backend/internal/api/handlers/handlers_test.go) - Add parallelization +4. [uptime_service_test.go](backend/internal/services/uptime_service_test.go) - Remove sleeps + +### Medium Priority +5. [proxy_host_handler_test.go](backend/internal/api/handlers/proxy_host_handler_test.go) +6. [crowdsec_handler_test.go](backend/internal/api/handlers/crowdsec_handler_test.go) +7. [auth_handler_test.go](backend/internal/api/handlers/auth_handler_test.go) +8. [notification_service_test.go](backend/internal/services/notification_service_test.go) + +### Low Priority (Minor Impact) +9. [benchmark_test.go](backend/internal/api/handlers/benchmark_test.go) +10. [security_handler_rules_decisions_test.go](backend/internal/api/handlers/security_handler_rules_decisions_test.go)