Files
Charon/backend/internal/api/handlers/security_handler_rules_decisions_test.go
GitHub Actions 032d475fba chore: remediate 61 Go linting issues and tighten pre-commit config
Complete lint remediation addressing errcheck, gosec, and staticcheck
violations across backend test files. Tighten pre-commit configuration
to prevent future blind spots.

Key Changes:
- Fix 61 Go linting issues (errcheck, gosec G115/G301/G304/G306, bodyclose)
- Add proper error handling for json.Unmarshal, os.Setenv, db.Close(), w.Write()
- Fix gosec G115 integer overflow with strconv.FormatUint
- Add #nosec annotations with justifications for test fixtures
- Fix SecurityService goroutine leaks (add Close() calls)
- Fix CrowdSec tar.gz non-deterministic ordering with sorted keys

Pre-commit Hardening:
- Remove test file exclusion from golangci-lint hook
- Add gosec to .golangci-fast.yml with critical checks (G101, G110, G305)
- Replace broad .golangci.yml exclusions with targeted path-specific rules
- Test files now linted on every commit

Test Fixes:
- Fix emergency route count assertions (1→2 for dual-port setup)
- Fix DNS provider service tests with proper mock setup
- Fix certificate service tests with deterministic behavior

Backend: 27 packages pass, 83.5% coverage
Frontend: 0 lint warnings, 0 TypeScript errors
Pre-commit: All 14 hooks pass (~37s)
2026-02-02 06:17:48 +00:00

175 lines
7.3 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/caddy"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
)
func setupSecurityTestRouterWithExtras(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
// Use a file-backed sqlite DB to avoid shared memory connection issues in tests
dsn := filepath.Join(t.TempDir(), "test.db")
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.AccessList{}, &models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{}))
r := gin.New()
api := r.Group("/api/v1")
cfg := config.SecurityConfig{}
h := NewSecurityHandler(cfg, db, nil)
api.POST("/security/decisions", h.CreateDecision)
api.GET("/security/decisions", h.ListDecisions)
api.POST("/security/rulesets", h.UpsertRuleSet)
api.GET("/security/rulesets", h.ListRuleSets)
api.DELETE("/security/rulesets/:id", h.DeleteRuleSet)
return r, db
}
func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) {
r, db := setupSecurityTestRouterWithExtras(t)
payload := `{"ip":"1.2.3.4","action":"block","host":"example.com","rule_id":"manual-1","details":"test"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/security/decisions", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("Create decision expected status 200, got %d; body: %s", resp.Code, resp.Body.String())
}
var decisionResp map[string]any
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &decisionResp))
require.NotNil(t, decisionResp["decision"])
req = httptest.NewRequest(http.MethodGet, "/api/v1/security/decisions?limit=10", http.NoBody)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("Upsert ruleset expected status 200, got %d; body: %s", resp.Code, resp.Body.String())
}
var listResp map[string][]map[string]any
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listResp))
require.GreaterOrEqual(t, len(listResp["decisions"]), 1)
// Now test ruleset upsert
rpayload := `{"name":"owasp-crs","source_url":"https://example.com/owasp","mode":"owasp","content":"test"}`
req = httptest.NewRequest(http.MethodPost, "/api/v1/security/rulesets", strings.NewReader(rpayload))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("Upsert ruleset expected status 200, got %d; body: %s", resp.Code, resp.Body.String())
}
var rsResp map[string]any
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &rsResp))
require.NotNil(t, rsResp["ruleset"])
req = httptest.NewRequest(http.MethodGet, "/api/v1/security/rulesets", http.NoBody)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("List rulesets expected status 200, got %d; body: %s", resp.Code, resp.Body.String())
}
var listRsResp map[string][]map[string]any
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listRsResp))
require.GreaterOrEqual(t, len(listRsResp["rulesets"]), 1)
// Delete the ruleset we just created
// Note: ID has json:"-" tag so we use UUID to look up the record from DB
rulesetUUID, ok := listRsResp["rulesets"][0]["uuid"].(string)
require.True(t, ok, "uuid should be present in response")
var ruleset models.SecurityRuleSet
require.NoError(t, db.Where("uuid = ?", rulesetUUID).First(&ruleset).Error)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.FormatUint(uint64(ruleset.ID), 10), http.NoBody)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
var delResp map[string]any
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &delResp))
require.Equal(t, true, delResp["deleted"].(bool))
}
func TestSecurityHandler_UpsertDeleteTriggersApplyConfig(t *testing.T) {
t.Helper()
// Setup DB
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{}))
// Ensure DB has expected tables (migrations executed above)
// Ensure proxy_hosts table exists in case AutoMigrate didn't create it
db.Exec("CREATE TABLE IF NOT EXISTS proxy_hosts (id INTEGER PRIMARY KEY AUTOINCREMENT, domain_names TEXT, forward_host TEXT, forward_port INTEGER, enabled BOOLEAN)")
// Create minimal settings and caddy_configs tables to satisfy Manager.ApplyConfig queries
db.Exec("CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT, value TEXT, type TEXT, category TEXT, updated_at datetime)")
db.Exec("CREATE TABLE IF NOT EXISTS caddy_configs (id INTEGER PRIMARY KEY AUTOINCREMENT, config_hash TEXT, applied_at datetime, success BOOLEAN, error_msg TEXT)")
// debug: tables exist
// Caddy admin server to capture /load calls
loadCh := make(chan struct{}, 2)
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/load" && r.Method == http.MethodPost {
loadCh <- struct{}{}
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer caddyServer.Close()
client := caddy.NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL))
tmp := t.TempDir()
m := caddy.NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
r := gin.New()
api := r.Group("/api/v1")
cfg := config.SecurityConfig{}
h := NewSecurityHandler(cfg, db, m)
api.POST("/security/rulesets", h.UpsertRuleSet)
api.DELETE("/security/rulesets/:id", h.DeleteRuleSet)
// Upsert ruleset should trigger manager.ApplyConfig -> POST /load
rpayload := `{"name":"owasp-crs","source_url":"https://example.com/owasp","mode":"owasp","content":"test"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/security/rulesets", strings.NewReader(rpayload))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
select {
case <-loadCh:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for manager ApplyConfig /load post on upsert")
}
// Now delete the ruleset and ensure /load is triggered again
// Read ID from DB
var rs models.SecurityRuleSet
assert.NoError(t, db.First(&rs).Error)
// Use FormatUint to avoid integer overflow when converting uint to int
req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.FormatUint(uint64(rs.ID), 10), http.NoBody)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
select {
case <-loadCh:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for manager ApplyConfig /load post on delete")
}
}