fix: increase memory limit for vitest and improve test stability
- Updated test scripts in package.json to set NODE_OPTIONS for increased memory limit. - Added safety checks for remote servers and domains in ProxyHostForm component to prevent errors. - Refactored Notifications tests to remove unnecessary use of fake timers and improve clarity. - Updated ProxyHosts extra tests to specify button names for better accessibility. - Enhanced Security functional tests by centralizing translation strings and improving mock implementations. - Adjusted test setup to suppress specific console errors related to act() warnings. - Modified vitest configuration to limit worker usage and prevent memory issues during testing.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParsePluginSignatures(t *testing.T) {
|
||||
t.Run("unset env returns nil", func(t *testing.T) {
|
||||
t.Setenv("CHARON_PLUGIN_SIGNATURES", "")
|
||||
signatures := parsePluginSignatures()
|
||||
if signatures != nil {
|
||||
t.Fatalf("expected nil signatures when env is unset, got: %#v", signatures)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid json returns nil", func(t *testing.T) {
|
||||
t.Setenv("CHARON_PLUGIN_SIGNATURES", "{invalid}")
|
||||
signatures := parsePluginSignatures()
|
||||
if signatures != nil {
|
||||
t.Fatalf("expected nil signatures for invalid json, got: %#v", signatures)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid prefix returns nil", func(t *testing.T) {
|
||||
t.Setenv("CHARON_PLUGIN_SIGNATURES", `{"plugin.so":"md5:deadbeef"}`)
|
||||
signatures := parsePluginSignatures()
|
||||
if signatures != nil {
|
||||
t.Fatalf("expected nil signatures for invalid prefix, got: %#v", signatures)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty allowlist returns empty map", func(t *testing.T) {
|
||||
t.Setenv("CHARON_PLUGIN_SIGNATURES", `{}`)
|
||||
signatures := parsePluginSignatures()
|
||||
if signatures == nil {
|
||||
t.Fatal("expected non-nil empty map for strict empty allowlist")
|
||||
}
|
||||
if len(signatures) != 0 {
|
||||
t.Fatalf("expected empty map, got: %#v", signatures)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid allowlist returns parsed map", func(t *testing.T) {
|
||||
t.Setenv("CHARON_PLUGIN_SIGNATURES", `{"plugin-a.so":"sha256:abc123","plugin-b.so":"sha256:def456"}`)
|
||||
signatures := parsePluginSignatures()
|
||||
if signatures == nil {
|
||||
t.Fatal("expected parsed signatures map, got nil")
|
||||
}
|
||||
if got := signatures["plugin-a.so"]; got != "sha256:abc123" {
|
||||
t.Fatalf("unexpected plugin-a signature: %q", got)
|
||||
}
|
||||
if got := signatures["plugin-b.so"]; got != "sha256:def456" {
|
||||
t.Fatalf("unexpected plugin-b signature: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -190,3 +190,94 @@ func TestStartupVerification_MissingTables(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain_MigrateCommand_InProcess(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dbPath := filepath.Join(tmp, "data", "test.db")
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir db dir: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.Connect(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("connect db: %v", err)
|
||||
}
|
||||
if err = db.AutoMigrate(&models.User{}); err != nil {
|
||||
t.Fatalf("automigrate user: %v", err)
|
||||
}
|
||||
|
||||
originalArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = originalArgs })
|
||||
|
||||
t.Setenv("CHARON_DB_PATH", dbPath)
|
||||
t.Setenv("CHARON_CADDY_CONFIG_DIR", filepath.Join(tmp, "caddy"))
|
||||
t.Setenv("CHARON_IMPORT_DIR", filepath.Join(tmp, "imports"))
|
||||
os.Args = []string{"charon", "migrate"}
|
||||
|
||||
main()
|
||||
|
||||
db2, err := database.Connect(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reconnect db: %v", err)
|
||||
}
|
||||
|
||||
securityModels := []any{
|
||||
&models.SecurityConfig{},
|
||||
&models.SecurityDecision{},
|
||||
&models.SecurityAudit{},
|
||||
&models.SecurityRuleSet{},
|
||||
&models.CrowdsecPresetEvent{},
|
||||
&models.CrowdsecConsoleEnrollment{},
|
||||
}
|
||||
|
||||
for _, model := range securityModels {
|
||||
if !db2.Migrator().HasTable(model) {
|
||||
t.Errorf("Table for %T was not created by migrate command", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain_ResetPasswordCommand_InProcess(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dbPath := filepath.Join(tmp, "data", "test.db")
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir db dir: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.Connect(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("connect db: %v", err)
|
||||
}
|
||||
if err = db.AutoMigrate(&models.User{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
email := "user@example.com"
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: "admin", Enabled: true}
|
||||
user.PasswordHash = "$2a$10$example_hashed_password"
|
||||
user.FailedLoginAttempts = 3
|
||||
if err = db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
|
||||
originalArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = originalArgs })
|
||||
|
||||
t.Setenv("CHARON_DB_PATH", dbPath)
|
||||
t.Setenv("CHARON_CADDY_CONFIG_DIR", filepath.Join(tmp, "caddy"))
|
||||
t.Setenv("CHARON_IMPORT_DIR", filepath.Join(tmp, "imports"))
|
||||
os.Args = []string{"charon", "reset-password", email, "new-password"}
|
||||
|
||||
main()
|
||||
|
||||
var updated models.User
|
||||
if err := db.Where("email = ?", email).First(&updated).Error; err != nil {
|
||||
t.Fatalf("fetch updated user: %v", err)
|
||||
}
|
||||
if updated.PasswordHash == "$2a$10$example_hashed_password" {
|
||||
t.Fatal("expected password hash to be updated")
|
||||
}
|
||||
if updated.FailedLoginAttempts != 0 {
|
||||
t.Fatalf("expected failed login attempts reset to 0, got %d", updated.FailedLoginAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSeedMain_Smoke(t *testing.T) {
|
||||
@@ -13,13 +19,15 @@ func TestSeedMain_Smoke(t *testing.T) {
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
err = os.Chdir(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(wd) })
|
||||
|
||||
// #nosec G301 -- Test data directory, 0o755 acceptable for test environment
|
||||
if err := os.MkdirAll("data", 0o755); err != nil {
|
||||
err = os.MkdirAll("data", 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir data: %v", err)
|
||||
}
|
||||
|
||||
@@ -30,3 +38,164 @@ func TestSeedMain_Smoke(t *testing.T) {
|
||||
t.Fatalf("expected db file to exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedMain_ForceAdminUpdatesExistingUserPassword(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
err = os.Chdir(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(wd)
|
||||
})
|
||||
|
||||
err = os.MkdirAll("data", 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir data: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join("data", "charon.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.User{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
seeded := models.User{
|
||||
UUID: "existing-user",
|
||||
Email: "admin@localhost",
|
||||
Name: "Old Name",
|
||||
Role: "viewer",
|
||||
Enabled: false,
|
||||
PasswordHash: "$2a$10$example_hashed_password",
|
||||
}
|
||||
if err := db.Create(&seeded).Error; err != nil {
|
||||
t.Fatalf("create seeded user: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("CHARON_FORCE_DEFAULT_ADMIN", "1")
|
||||
t.Setenv("CHARON_DEFAULT_ADMIN_PASSWORD", "new-password")
|
||||
|
||||
main()
|
||||
|
||||
var updated models.User
|
||||
if err := db.Where("email = ?", "admin@localhost").First(&updated).Error; err != nil {
|
||||
t.Fatalf("fetch updated user: %v", err)
|
||||
}
|
||||
|
||||
if updated.PasswordHash == "$2a$10$example_hashed_password" {
|
||||
t.Fatal("expected password hash to be updated for forced admin")
|
||||
}
|
||||
if updated.Role != "admin" {
|
||||
t.Fatalf("expected role admin, got %q", updated.Role)
|
||||
}
|
||||
if !updated.Enabled {
|
||||
t.Fatal("expected forced admin to be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedMain_ForceAdminWithoutPasswordUpdatesMetadata(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
err = os.Chdir(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(wd)
|
||||
})
|
||||
|
||||
err = os.MkdirAll("data", 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir data: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join("data", "charon.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.User{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
seeded := models.User{
|
||||
UUID: "existing-user-no-pass",
|
||||
Email: "admin@localhost",
|
||||
Name: "Old Name",
|
||||
Role: "viewer",
|
||||
Enabled: false,
|
||||
PasswordHash: "$2a$10$example_hashed_password",
|
||||
}
|
||||
if err := db.Create(&seeded).Error; err != nil {
|
||||
t.Fatalf("create seeded user: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("CHARON_FORCE_DEFAULT_ADMIN", "1")
|
||||
t.Setenv("CHARON_DEFAULT_ADMIN_PASSWORD", "")
|
||||
|
||||
main()
|
||||
|
||||
var updated models.User
|
||||
if err := db.Where("email = ?", "admin@localhost").First(&updated).Error; err != nil {
|
||||
t.Fatalf("fetch updated user: %v", err)
|
||||
}
|
||||
|
||||
if updated.Role != "admin" {
|
||||
t.Fatalf("expected role admin, got %q", updated.Role)
|
||||
}
|
||||
if !updated.Enabled {
|
||||
t.Fatal("expected forced admin to be enabled")
|
||||
}
|
||||
if updated.PasswordHash != "$2a$10$example_hashed_password" {
|
||||
t.Fatal("expected password hash to remain unchanged when no password is provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogSeedResult_Branches(t *testing.T) {
|
||||
entry := logrus.New().WithField("component", "seed-test")
|
||||
|
||||
t.Run("error branch", func(t *testing.T) {
|
||||
createdCalled := false
|
||||
result := &gorm.DB{Error: errors.New("insert failed")}
|
||||
logSeedResult(entry, result, "error", func() {
|
||||
createdCalled = true
|
||||
}, "exists")
|
||||
if createdCalled {
|
||||
t.Fatal("created callback should not be called on error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("created branch", func(t *testing.T) {
|
||||
createdCalled := false
|
||||
result := &gorm.DB{RowsAffected: 1}
|
||||
logSeedResult(entry, result, "error", func() {
|
||||
createdCalled = true
|
||||
}, "exists")
|
||||
if !createdCalled {
|
||||
t.Fatal("created callback should be called when rows are affected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exists branch", func(t *testing.T) {
|
||||
createdCalled := false
|
||||
result := &gorm.DB{RowsAffected: 0}
|
||||
logSeedResult(entry, result, "error", func() {
|
||||
createdCalled = true
|
||||
}, "exists")
|
||||
if createdCalled {
|
||||
t.Fatal("created callback should not be called when rows are not affected")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -1040,6 +1041,33 @@ func TestAuthHandler_HelperFunctions(t *testing.T) {
|
||||
assert.Equal(t, "https", requestScheme(ctx))
|
||||
})
|
||||
|
||||
t.Run("requestScheme uses tls when forwarded proto missing", func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com", http.NoBody)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
ctx.Request = req
|
||||
assert.Equal(t, "https", requestScheme(ctx))
|
||||
})
|
||||
|
||||
t.Run("requestScheme uses request url scheme when available", func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com", http.NoBody)
|
||||
req.URL.Scheme = "HTTP"
|
||||
ctx.Request = req
|
||||
assert.Equal(t, "http", requestScheme(ctx))
|
||||
})
|
||||
|
||||
t.Run("requestScheme defaults to http when request url is nil", func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com", http.NoBody)
|
||||
req.URL = nil
|
||||
ctx.Request = req
|
||||
assert.Equal(t, "http", requestScheme(ctx))
|
||||
})
|
||||
|
||||
t.Run("normalizeHost strips brackets and port", func(t *testing.T) {
|
||||
assert.Equal(t, "::1", normalizeHost("[::1]:443"))
|
||||
assert.Equal(t, "example.com", normalizeHost("example.com:8080"))
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -15,6 +16,31 @@ import (
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func TestIsSQLiteTransientRehydrateError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "nil error", err: nil, want: false},
|
||||
{name: "database is locked", err: errors.New("database is locked"), want: true},
|
||||
{name: "database is busy", err: errors.New("database is busy"), want: true},
|
||||
{name: "database table is locked", err: errors.New("database table is locked"), want: true},
|
||||
{name: "table is locked", err: errors.New("table is locked"), want: true},
|
||||
{name: "resource busy", err: errors.New("resource busy"), want: true},
|
||||
{name: "mixed-case transient message", err: errors.New("Database Is Locked"), want: true},
|
||||
{name: "non-transient error", err: errors.New("constraint failed"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, isSQLiteTransientRehydrateError(tt.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setupBackupTest(t *testing.T) (*gin.Engine, *services.BackupService, string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -224,3 +224,67 @@ func TestAuthService_ValidateToken_EdgeCases(t *testing.T) {
|
||||
_ = user
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthService_AuthenticateToken(t *testing.T) {
|
||||
db := setupAuthTestDB(t)
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
service := NewAuthService(db, cfg)
|
||||
|
||||
user, err := service.Register("auth@example.com", "password123", "Auth User")
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := service.Login("auth@example.com", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
authUser, claims, authErr := service.AuthenticateToken(token)
|
||||
require.NoError(t, authErr)
|
||||
require.NotNil(t, authUser)
|
||||
require.NotNil(t, claims)
|
||||
assert.Equal(t, user.ID, authUser.ID)
|
||||
assert.Equal(t, user.ID, claims.UserID)
|
||||
})
|
||||
|
||||
t.Run("invalidated_session_version", func(t *testing.T) {
|
||||
require.NoError(t, service.InvalidateSessions(user.ID))
|
||||
_, _, authErr := service.AuthenticateToken(token)
|
||||
require.Error(t, authErr)
|
||||
assert.Equal(t, "invalid token", authErr.Error())
|
||||
})
|
||||
|
||||
t.Run("disabled_user", func(t *testing.T) {
|
||||
user2, regErr := service.Register("disabled@example.com", "password123", "Disabled User")
|
||||
require.NoError(t, regErr)
|
||||
|
||||
token2, loginErr := service.Login("disabled@example.com", "password123")
|
||||
require.NoError(t, loginErr)
|
||||
|
||||
require.NoError(t, db.Model(&models.User{}).Where("id = ?", user2.ID).Update("enabled", false).Error)
|
||||
|
||||
_, _, authErr := service.AuthenticateToken(token2)
|
||||
require.Error(t, authErr)
|
||||
assert.Equal(t, "invalid token", authErr.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthService_InvalidateSessions(t *testing.T) {
|
||||
db := setupAuthTestDB(t)
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
service := NewAuthService(db, cfg)
|
||||
|
||||
user, err := service.Register("invalidate@example.com", "password123", "Invalidate User")
|
||||
require.NoError(t, err)
|
||||
|
||||
var before models.User
|
||||
require.NoError(t, db.Where("id = ?", user.ID).First(&before).Error)
|
||||
|
||||
require.NoError(t, service.InvalidateSessions(user.ID))
|
||||
|
||||
var after models.User
|
||||
require.NoError(t, db.Where("id = ?", user.ID).First(&after).Error)
|
||||
assert.Equal(t, before.SessionVersion+1, after.SessionVersion)
|
||||
|
||||
err = service.InvalidateSessions(999999)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "user not found", err.Error())
|
||||
}
|
||||
|
||||
@@ -1338,19 +1338,20 @@ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) {
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
t.Run("discord_message_is_normalized_to_content", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
originalDo := webhookDoRequestFunc
|
||||
defer func() { webhookDoRequestFunc = originalDo }()
|
||||
webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
var payload map[string]any
|
||||
err := json.NewDecoder(r.Body).Decode(&payload)
|
||||
err := json.NewDecoder(req.Body).Decode(&payload)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Test Message", payload["content"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
|
||||
}
|
||||
|
||||
// Discord payload with message should be normalized to content
|
||||
provider := models.NotificationProvider{
|
||||
Type: "discord",
|
||||
URL: server.URL,
|
||||
URL: "https://discord.com/api/webhooks/123456/token_abc",
|
||||
Template: "custom",
|
||||
Config: `{"message": {{toJSON .Message}}}`,
|
||||
}
|
||||
@@ -1366,14 +1367,15 @@ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("discord_with_content_succeeds", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
originalDo := webhookDoRequestFunc
|
||||
defer func() { webhookDoRequestFunc = originalDo }()
|
||||
webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
|
||||
}
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "discord",
|
||||
URL: server.URL,
|
||||
URL: "https://discord.com/api/webhooks/123456/token_abc",
|
||||
Template: "custom",
|
||||
Config: `{"content": {{toJSON .Message}}}`,
|
||||
}
|
||||
@@ -1389,14 +1391,15 @@ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("discord_with_embeds_succeeds", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
originalDo := webhookDoRequestFunc
|
||||
defer func() { webhookDoRequestFunc = originalDo }()
|
||||
webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header)}, nil
|
||||
}
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "discord",
|
||||
URL: server.URL,
|
||||
URL: "https://discord.com/api/webhooks/123456/token_abc",
|
||||
Template: "custom",
|
||||
Config: `{"embeds": [{"title": {{toJSON .Title}}}]}`,
|
||||
}
|
||||
|
||||
+130
-392
@@ -1,402 +1,140 @@
|
||||
## Frontend Coverage Fast-Recovery Plan (Minimum Threshold First)
|
||||
## Coverage Scope Refinement Spec (Authoritative)
|
||||
|
||||
Date: 2026-02-16
|
||||
Owner: Planning Agent
|
||||
Scope: Raise frontend unit-test coverage to project minimum quickly, without destabilizing ongoing flaky E2E CI validation.
|
||||
Status: Active / Authoritative for this objective only
|
||||
|
||||
## 1) Objective
|
||||
|
||||
Recover frontend coverage to the minimum required gate with the fewest
|
||||
iterations by targeting the biggest low-coverage modules first, starting
|
||||
with high-yield API files and then selected large UI files.
|
||||
|
||||
Primary gate:
|
||||
- Frontend lines coverage >= 85% (Codecov project `frontend` + local Vitest gate)
|
||||
|
||||
Hard constraints:
|
||||
- Do not modify production behavior unless a testability blocker is proven.
|
||||
- Keep E2E stabilization work isolated (final flaky E2E already in CI validation).
|
||||
|
||||
## 2) Research Findings (Current Snapshot)
|
||||
|
||||
Baseline sources discovered:
|
||||
- `frontend/coverage.log` (recent Vitest coverage table with uncovered ranges)
|
||||
- `frontend/vitest.config.ts` (default gate from `CHARON_MIN_COVERAGE`/`CPM_MIN_COVERAGE`, fallback `85.0`)
|
||||
- `codecov.yml` (frontend project target `85%`, patch target `100%`)
|
||||
|
||||
Observed recent baseline in `frontend/coverage.log`:
|
||||
- All files lines: `86.91%`
|
||||
- Note: this run used a stricter environment gate (`88%`) and failed that stricter gate.
|
||||
|
||||
### Ranked High-Yield Candidates (size + low current line coverage)
|
||||
|
||||
Estimates below use current file length as approximation and prioritize
|
||||
modules where added tests can cover many currently uncovered lines quickly.
|
||||
|
||||
| Rank | Module | File lines | Current line coverage | Approx uncovered lines | Existing test target to extend | Expected project lines impact |
|
||||
|---|---|---:|---:|---:|---|---|
|
||||
| 1 | `src/api/securityHeaders.ts` | 188 | 10.00% | ~169 | `frontend/src/api/__tests__/securityHeaders.test.ts` | +2.0% to +3.2% |
|
||||
| 2 | `src/api/import.ts` | 137 | 31.57% | ~94 | `frontend/src/api/__tests__/import.test.ts` | +1.0% to +1.9% |
|
||||
| 3 | `src/pages/UsersPage.tsx` | 775 | 75.67% | ~189 | `frontend/src/pages/__tests__/UsersPage.test.tsx` | +0.5% to +1.3% |
|
||||
| 4 | `src/pages/Security.tsx` | 643 | 72.22% | ~179 | `frontend/src/pages/__tests__/Security.test.tsx` | +0.4% to +1.1% |
|
||||
| 5 | `src/pages/Uptime.tsx` | 591 | 74.77% | ~149 | `frontend/src/pages/__tests__/Uptime.test.tsx` | +0.4% to +1.0% |
|
||||
| 6 | `src/pages/SecurityHeaders.tsx` | 340 | 69.35% | ~104 | `frontend/src/pages/__tests__/SecurityHeaders.test.tsx` | +0.3% to +0.9% |
|
||||
| 7 | `src/pages/Plugins.tsx` | 391 | 62.26% | ~148 | `frontend/src/pages/__tests__/Plugins.test.tsx` | +0.3% to +0.9% |
|
||||
| 8 | `src/components/SecurityHeaderProfileForm.tsx` | 467 | 58.97% | ~192 | `frontend/src/components/__tests__/SecurityHeaderProfileForm.test.tsx` | +0.4% to +1.0% |
|
||||
| 9 | `src/components/CredentialManager.tsx` | 609 | 75.75% | ~148 | `frontend/src/components/__tests__/CredentialManager.test.tsx` | +0.3% to +0.8% |
|
||||
| 10 | `src/api/client.ts` | 71 | 34.78% | ~46 | `frontend/src/api/__tests__/client.test.ts` | +0.2% to +0.6% |
|
||||
|
||||
Planning decision:
|
||||
- Start with API modules (`securityHeaders.ts`, `import.ts`, optional `client.ts`) for fastest coverage-per-test effort.
|
||||
- Move to large pages only if the threshold is still not satisfied.
|
||||
|
||||
## 3) Requirements in EARS Notation
|
||||
- WHEN frontend lines coverage is below the minimum threshold, THE SYSTEM SHALL prioritize modules by uncovered size and low coverage first.
|
||||
- WHEN selecting coverage targets, THE SYSTEM SHALL use existing test files before creating new test files.
|
||||
- WHEN collecting baseline and post-change metrics, THE SYSTEM SHALL use project-approved tasks/scripts only.
|
||||
- WHEN E2E is already being validated in CI, THE SYSTEM SHALL avoid introducing E2E test/config changes in this coverage effort.
|
||||
- IF frontend threshold is still not met after minimal path execution, THEN THE SYSTEM SHALL execute fallback targets in priority order until threshold is met.
|
||||
- WHEN coverage work is complete, THE SYSTEM SHALL pass frontend coverage gate, type-check, and manual pre-commit checks required by project testing instructions.
|
||||
|
||||
|
||||
## 4) Technical Specification (Coverage-Only)
|
||||
|
||||
### In Scope
|
||||
- Frontend unit tests (Vitest) only.
|
||||
- Extending existing tests under:
|
||||
- `frontend/src/api/__tests__/`
|
||||
- `frontend/src/pages/__tests__/`
|
||||
- `frontend/src/components/__tests__/`
|
||||
|
||||
### Out of Scope
|
||||
- Backend changes.
|
||||
- Playwright test logic changes.
|
||||
- CI workflow redesign.
|
||||
- Product behavior changes unrelated to testability.
|
||||
|
||||
### No Schema/API Contract Changes
|
||||
- No backend API contract changes are required for this plan.
|
||||
- No database changes are required.
|
||||
|
||||
## 5) Phased Execution Plan
|
||||
|
||||
### Phase 0 — Baseline and Target Lock (single pass)
|
||||
|
||||
Goal: establish current truth and exact gap to 85%.
|
||||
|
||||
1. Run approved frontend coverage task:
|
||||
- Preferred: VS Code task `Test: Frontend Coverage (Vitest)`
|
||||
- Equivalent script path: `.github/skills/scripts/skill-runner.sh test-frontend-coverage`
|
||||
2. Capture baseline artifacts:
|
||||
- `frontend/coverage/coverage-summary.json`
|
||||
- `frontend/coverage/lcov.info`
|
||||
3. Record baseline:
|
||||
- total lines pct
|
||||
- delta to 85%
|
||||
- top 10 uncovered modules (from `coverage.log`/summary)
|
||||
4. Early-exit gate (immediately after baseline capture):
|
||||
- Read active threshold from the same gate source used by the coverage task
|
||||
(`CHARON_MIN_COVERAGE`/`CPM_MIN_COVERAGE`, fallback `85.0`).
|
||||
- IF baseline frontend lines pct is already >= active threshold,
|
||||
THEN stop further test additions for this plan cycle.
|
||||
|
||||
Exit criteria:
|
||||
- Baseline numbers captured once and frozen for this cycle.
|
||||
- Either baseline is below threshold and Phase 1 proceeds, or execution exits
|
||||
early because baseline already meets/exceeds threshold.
|
||||
|
||||
### Phase 1 — Minimal Path (fewest requests/iterations)
|
||||
|
||||
Goal: cross 85% quickly with smallest change set.
|
||||
|
||||
Target set A (execute in order):
|
||||
1. `frontend/src/api/__tests__/securityHeaders.test.ts`
|
||||
2. `frontend/src/api/__tests__/import.test.ts`
|
||||
3. `frontend/src/api/__tests__/client.test.ts` (only if needed after #1-#2)
|
||||
|
||||
Test focus inside these files:
|
||||
- error mapping branches
|
||||
- non-2xx response handling
|
||||
- optional parameter/query serialization branches
|
||||
- retry/timeout/cancel edge paths where already implemented
|
||||
|
||||
Validation after each target (or pair):
|
||||
- run frontend coverage task
|
||||
- read updated total lines pct
|
||||
- stop as soon as >= 85%
|
||||
|
||||
Expected result:
|
||||
- Most likely to reach threshold within 2-3 targeted API test updates.
|
||||
|
||||
### Phase 2 — Secondary Path (only if still below threshold)
|
||||
|
||||
Goal: add one large UI target at a time, highest projected return first.
|
||||
|
||||
Target set B (execute in order, stop once >= 85%):
|
||||
1. `frontend/src/pages/__tests__/UsersPage.test.tsx`
|
||||
2. `frontend/src/pages/__tests__/Uptime.test.tsx`
|
||||
3. `frontend/src/pages/__tests__/SecurityHeaders.test.tsx`
|
||||
4. `frontend/src/pages/__tests__/Plugins.test.tsx`
|
||||
5. `frontend/src/components/__tests__/SecurityHeaderProfileForm.test.tsx`
|
||||
6. `frontend/src/pages/__tests__/Security.test.tsx` (de-prioritized because
|
||||
it is currently fully skipped; only consider if skip state is removed)
|
||||
|
||||
Test focus:
|
||||
- critical uncovered conditional render branches
|
||||
- form validation and submit error paths
|
||||
- loading/empty/error states not currently asserted
|
||||
|
||||
### Phase 3 — Final Verification and Gates
|
||||
|
||||
1. E2E verification-first check (run-first policy):
|
||||
- Run `Test: E2E Playwright (Skill)` first.
|
||||
- Use `Test: E2E Playwright (Targeted Suite)` only when scoped execution is
|
||||
sufficient for the impacted area.
|
||||
- No E2E code/config changes are allowed in this plan.
|
||||
2. Frontend coverage:
|
||||
- VS Code task `Test: Frontend Coverage (Vitest)`
|
||||
3. Frontend type-check:
|
||||
- VS Code task `Lint: TypeScript Check`
|
||||
4. Manual pre-commit checks:
|
||||
- VS Code task `Lint: Pre-commit (All Files)`
|
||||
5. Confirm Codecov expectations:
|
||||
- project frontend target >= 85%
|
||||
- patch coverage target = 100% for modified lines
|
||||
|
||||
## 6) Baseline vs Post-Change Collection Protocol
|
||||
|
||||
Use this exact protocol for both baseline and post-change snapshots:
|
||||
|
||||
1. Execute `Test: Frontend Coverage (Vitest)`.
|
||||
2. Save metrics from `frontend/coverage/coverage-summary.json`:
|
||||
- lines, statements, functions, branches.
|
||||
3. Keep `frontend/coverage/lcov.info` as Codecov upload source.
|
||||
4. Compare baseline vs post:
|
||||
- absolute lines pct delta
|
||||
- per-target module line pct deltas
|
||||
|
||||
Reporting format:
|
||||
- `Baseline lines: X%`
|
||||
- `Post lines: Y%`
|
||||
- `Net gain: (Y - X)%`
|
||||
- `Threshold status: PASS/FAIL`
|
||||
|
||||
## 6.1) Codecov Patch Triage (Explicit, Required)
|
||||
|
||||
Patch triage must capture exact missing/partial patch ranges from Codecov Patch
|
||||
view and map each range to concrete tests.
|
||||
|
||||
Required workflow:
|
||||
1. Open PR Patch view in Codecov.
|
||||
2. Copy exact missing/partial ranges into the table below.
|
||||
3. Map each range to a specific test file and test case additions.
|
||||
4. Re-run coverage and update status until all listed ranges are covered.
|
||||
|
||||
Required triage table template:
|
||||
|
||||
| File | Patch status | Exact missing/partial patch range(s) | Uncovered patch lines | Mapped test file | Planned test case(s) for exact ranges | Status |
|
||||
|---|---|---|---:|---|---|---|
|
||||
| `frontend/src/...` | Missing or Partial | `Lxx-Lyy`; `Laa-Lbb` | 0 | `frontend/src/.../__tests__/...test.ts[x]` | `it('...')` cases covering each listed range | Open / In Progress / Done |
|
||||
|
||||
Rules:
|
||||
- Ranges must be copied exactly from Codecov Patch view (not paraphrased).
|
||||
- Non-contiguous ranges must be listed explicitly.
|
||||
- Do not mark triage complete until every listed range is covered by passing tests.
|
||||
|
||||
## 7) Risk Controls (Protect Ongoing Flaky E2E Validation)
|
||||
- No edits to Playwright specs, fixtures, config, sharding, retries, or setup.
|
||||
- No edits to `.docker/compose/docker-compose.playwright-*.yml`.
|
||||
- No edits to global app runtime behavior unless a testability blocker is proven.
|
||||
- Keep all changes inside frontend unit tests unless absolutely required.
|
||||
- Run E2E verification-first as an execution gate using approved task labels,
|
||||
but make no E2E test/config changes; keep flaky E2E stabilization scope in CI.
|
||||
|
||||
|
||||
## 8) Minimal Path and Fallback Path
|
||||
|
||||
### Minimal Path (default)
|
||||
- Baseline once.
|
||||
- Apply the early-exit gate immediately after baseline capture.
|
||||
- Update tests for:
|
||||
1) `securityHeaders.ts`
|
||||
2) `import.ts`
|
||||
- Re-run coverage.
|
||||
- If >= 85%, stop and verify final gates.
|
||||
|
||||
### Fallback Path (if still below threshold)
|
||||
- Add `client.ts` API tests.
|
||||
- If still below, add one UI target at a time in this order:
|
||||
`UsersPage` -> `Uptime` -> `SecurityHeaders` -> `Plugins` -> `SecurityHeaderProfileForm` -> `Security (de-prioritized while fully skipped)`.
|
||||
- Re-run coverage after each addition; stop immediately when threshold is reached.
|
||||
|
||||
## 9) Config/Ignore File Recommendations (Only If Needed)
|
||||
|
||||
Current assessment for this effort:
|
||||
- `.gitignore`: already excludes frontend coverage artifacts.
|
||||
- `codecov.yml`: already enforces frontend 85% and patch 100% with suitable ignores.
|
||||
- `.dockerignore`: already excludes frontend coverage/test artifacts from image context.
|
||||
- `Dockerfile`: no changes required for unit coverage-only work.
|
||||
|
||||
Decision:
|
||||
- No config-file modifications are required for this coverage recovery task.
|
||||
|
||||
## 10) Acceptance Checklist
|
||||
- [ ] Baseline coverage collected with approved frontend coverage task.
|
||||
- [ ] Target ranking confirmed from largest-lowest-coverage modules.
|
||||
- [ ] Minimal path executed first (API targets before UI targets).
|
||||
- [ ] Early-exit gate applied after baseline capture.
|
||||
- [ ] Frontend lines coverage >= 85%.
|
||||
- [ ] TypeScript check passes.
|
||||
- [ ] Manual pre-commit run passes.
|
||||
- [ ] E2E verification-first gate executed with approved task labels (no E2E code/config changes).
|
||||
- [ ] No Playwright/E2E infra changes introduced.
|
||||
- [ ] Post-change coverage summary recorded.
|
||||
- [ ] Codecov patch triage table completed with exact missing/partial ranges and mapped tests.
|
||||
|
||||
|
||||
## 11) Definition of Done (Coverage Task Specific)
|
||||
|
||||
Done is achieved only when all are true:
|
||||
1. Frontend lines coverage is >= 85% using project-approved coverage task output.
|
||||
2. Coverage gains come from targeted high-yield modules listed in this plan.
|
||||
3. Type-check and manual pre-commit checks pass.
|
||||
4. No changes were made that destabilize or alter flaky E2E CI validation scope.
|
||||
5. Codecov patch coverage expectations remain satisfiable (100% for modified lines).
|
||||
6. Baseline/post-change metrics and final threshold status are documented in the task handoff.
|
||||
|
||||
## 12) Backend Patch-Coverage Remediation (Additive, Frontend Plan Intact)
|
||||
|
||||
Date: 2026-02-16
|
||||
Owner: Planning Agent
|
||||
Scope: Add backend-only remediation to recover Codecov patch coverage for changed backend lines while preserving existing frontend unit-test coverage work.
|
||||
|
||||
### 12.1 Objective and Constraints
|
||||
|
||||
- Raise backend patch coverage by targeting missing/partial changed lines from Codecov Patch view.
|
||||
- Preserve all existing frontend coverage plan content and execution order; this section is additive only.
|
||||
- No E2E requirement for this backend remediation section.
|
||||
- Source of truth for prioritization and totals is the provided Codecov patch report.
|
||||
|
||||
### 12.2 Codecov Patch Snapshot (Source of Truth)
|
||||
|
||||
Current patch coverage: **58.78378%**
|
||||
|
||||
Files with missing/partial changed lines:
|
||||
|
||||
| Priority | File | Patch % | Missing | Partial |
|
||||
|---|---|---:|---:|---:|
|
||||
| 1 | `backend/internal/services/mail_service.go` | 0.00% | 22 | 0 |
|
||||
| 2 | `backend/internal/crowdsec/hub_sync.go` | 0.00% | 10 | 6 |
|
||||
| 3 | `backend/internal/api/handlers/auth_handler.go` | 0.00% | 15 | 0 |
|
||||
| 4 | `backend/internal/services/backup_service.go` | 0.00% | 5 | 3 |
|
||||
| 5 | `backend/internal/services/proxyhost_service.go` | 55.88% | 14 | 1 |
|
||||
| 6 | `backend/internal/api/handlers/crowdsec_handler.go` | 30.00% | 9 | 5 |
|
||||
| 7 | `backend/internal/api/handlers/user_handler.go` | 72.09% | 6 | 6 |
|
||||
| 8 | `backend/internal/services/log_service.go` | 73.91% | 6 | 6 |
|
||||
| 9 | `backend/internal/api/handlers/import_handler.go` | 67.85% | 3 | 6 |
|
||||
| 10 | `backend/internal/cerberus/rate_limit.go` | 93.33% | 3 | 3 |
|
||||
|
||||
Execution rule: **zero-coverage files are first-pass mandatory** for fastest patch gain.
|
||||
|
||||
### 12.3 Explicit Patch-Triage Table (Exact Range Placeholders + Test Targets)
|
||||
|
||||
Populate exact ranges from local Codecov Patch output before/while implementing tests.
|
||||
|
||||
| File | Codecov exact missing/partial range placeholders (fill from local output) | Mapped backend test file target(s) | Planned test focus for those exact ranges | Status |
|
||||
|---|---|---|---|---|
|
||||
| `backend/internal/services/mail_service.go` | Missing: `L<mail-m1>-L<mail-m2>`, `L<mail-m3>-L<mail-m4>` | `backend/internal/services/mail_service_test.go` | happy path send/build; SMTP/auth error path; boundary for empty recipient/template vars | Open |
|
||||
| `backend/internal/crowdsec/hub_sync.go` | Missing: `L<hub-m1>-L<hub-m2>`; Partial: `L<hub-p1>-L<hub-p2>` | `backend/internal/crowdsec/hub_sync_test.go` | happy sync success; HTTP/non-200 + decode/network errors; boundary/partial branch on optional fields and empty decisions list | Open |
|
||||
| `backend/internal/api/handlers/auth_handler.go` | Missing: `L<auth-m1>-L<auth-m2>`, `L<auth-m3>-L<auth-m4>` | `backend/internal/api/handlers/auth_handler_test.go` | happy login/refresh/logout response; invalid payload/credentials error path; boundary on missing token/cookie/header branches | Open |
|
||||
| `backend/internal/services/backup_service.go` | Missing: `L<backup-m1>-L<backup-m2>`; Partial: `L<backup-p1>-L<backup-p2>` | `backend/internal/services/backup_service_test.go` | happy backup/restore flow; fs/io/sql error path; boundary/partial for empty backup set and retention edge | Open |
|
||||
| `backend/internal/services/proxyhost_service.go` | Missing: `L<proxyhost-m1>-L<proxyhost-m2>`; Partial: `L<proxyhost-p1>-L<proxyhost-p2>` | `backend/internal/services/proxyhost_service_test.go` | happy create/update/delete/list; validation/duplicate/not-found error path; boundary/partial for optional TLS/security toggles | Open |
|
||||
| `backend/internal/api/handlers/crowdsec_handler.go` | Missing: `L<crowdsec-handler-m1>-L<crowdsec-handler-m2>`; Partial: `L<crowdsec-handler-p1>-L<crowdsec-handler-p2>` | `backend/internal/api/handlers/crowdsec_handler_test.go` | happy config/get/update actions; bind/service error path; boundary/partial on query params and empty payload behavior | Open |
|
||||
| `backend/internal/api/handlers/user_handler.go` | Missing: `L<user-handler-m1>-L<user-handler-m2>`; Partial: `L<user-handler-p1>-L<user-handler-p2>` | `backend/internal/api/handlers/user_handler_test.go` | happy list/create/update/delete; validation/permission/not-found errors; boundary/partial for pagination/filter defaults | Open |
|
||||
| `backend/internal/services/log_service.go` | Missing: `L<log-service-m1>-L<log-service-m2>`; Partial: `L<log-service-p1>-L<log-service-p2>` | `backend/internal/services/log_service_test.go` | happy read/stream/filter; source/read failure path; boundary/partial for empty logs and limit/offset branches | Open |
|
||||
| `backend/internal/api/handlers/import_handler.go` | Missing: `L<import-handler-m1>-L<import-handler-m2>`; Partial: `L<import-handler-p1>-L<import-handler-p2>` | `backend/internal/api/handlers/import_handler_test.go` | happy import start/status; bind/parse/service failure path; boundary/partial for unsupported type/empty payload | Open |
|
||||
| `backend/internal/cerberus/rate_limit.go` | Missing: `L<rate-limit-m1>-L<rate-limit-m2>`; Partial: `L<rate-limit-p1>-L<rate-limit-p2>` | `backend/internal/cerberus/rate_limit_test.go` | happy allow path; blocked/over-limit error path; boundary/partial for burst/window thresholds | Open |
|
||||
|
||||
Patch triage completion rule:
|
||||
- Replace placeholders with exact Codecov ranges and keep one-to-one mapping between each range and at least one concrete test case.
|
||||
- Do not close this remediation until every listed placeholder range is replaced and verified as covered.
|
||||
|
||||
### 12.4 Backend Test Strategy by File (Happy + Error + Boundary/Partial)
|
||||
|
||||
#### Wave 1 — Zero-Coverage First (fastest patch gain)
|
||||
|
||||
1. `backend/internal/services/mail_service.go` (0.00%, 22 missing)
|
||||
- Happy: successful send/build with valid config.
|
||||
- Error: transport/auth/template failures.
|
||||
- Boundary/partial: empty optional fields, nil config branches.
|
||||
|
||||
2. `backend/internal/crowdsec/hub_sync.go` (0.00%, 10 missing + 6 partial)
|
||||
- Happy: successful hub sync and update path.
|
||||
- Error: HTTP error, non-2xx response, malformed payload.
|
||||
- Boundary/partial: empty decision set, optional/legacy field branches.
|
||||
|
||||
3. `backend/internal/api/handlers/auth_handler.go` (0.00%, 15 missing)
|
||||
- Happy: valid auth request returns expected status/body.
|
||||
- Error: bind/validation/service auth failures.
|
||||
- Boundary/partial: missing header/cookie/token branches.
|
||||
|
||||
4. `backend/internal/services/backup_service.go` (0.00%, 5 missing + 3 partial)
|
||||
- Happy: backup create/list/restore success branch.
|
||||
- Error: filesystem/database operation failures.
|
||||
- Boundary/partial: empty backup inventory and retention-window edges.
|
||||
|
||||
#### Wave 2 — Mid-Coverage Expansion
|
||||
|
||||
5. `backend/internal/services/proxyhost_service.go` (55.88%, 14 missing + 1 partial)
|
||||
6. `backend/internal/api/handlers/crowdsec_handler.go` (30.00%, 9 missing + 5 partial)
|
||||
|
||||
For both:
|
||||
- Happy path operation success.
|
||||
- Error path for validation/bind/service failures.
|
||||
- Boundary/partial branches for optional flags/default values.
|
||||
Narrow scope only:
|
||||
- Remove frontend unit-test quarantine execution excludes in
|
||||
`frontend/vitest.config.ts`.
|
||||
- Include selected unit-test-related code in coverage accounting by updating
|
||||
`codecov.yml` and `scripts/go-test-coverage.sh`.
|
||||
- Keep integration/trace exclusions unchanged.
|
||||
- Keep Docker-only backend exclusions unchanged unless a deterministic,
|
||||
CI-testable strategy is explicitly added (not part of this plan).
|
||||
|
||||
#### Wave 3 — High-Coverage Tail Cleanup
|
||||
|
||||
7. `backend/internal/api/handlers/user_handler.go` (72.09%, 6 missing + 6 partial)
|
||||
8. `backend/internal/services/log_service.go` (73.91%, 6 missing + 6 partial)
|
||||
9. `backend/internal/api/handlers/import_handler.go` (67.85%, 3 missing + 6 partial)
|
||||
10. `backend/internal/cerberus/rate_limit.go` (93.33%, 3 missing + 3 partial)
|
||||
|
||||
For all:
|
||||
- Close remaining missing branches first.
|
||||
- Then resolve partials with targeted boundary tests matching exact triaged ranges.
|
||||
Out of scope:
|
||||
- E2E implementation changes.
|
||||
- Integration coverage model changes.
|
||||
- Backend Docker socket-dependent coverage inclusion.
|
||||
- Legacy additive coverage programs not required for this objective.
|
||||
|
||||
### 12.5 Concrete Run Sequence (Backend Coverage + Targeted Tests + Re-run)
|
||||
## 2) Blocker Resolution Mapping
|
||||
|
||||
Use this execution order for each wave:
|
||||
|
||||
1. Baseline backend coverage (project-approved):
|
||||
- VS Code task: `Test: Backend with Coverage` (if present)
|
||||
- Script: `scripts/go-test-coverage.sh`
|
||||
|
||||
2. Targeted package-level test runs for quick feedback:
|
||||
- `cd /projects/Charon/backend && go test -cover ./internal/services -run 'Mail|Backup|ProxyHost|Log'`
|
||||
- `cd /projects/Charon/backend && go test -cover ./internal/crowdsec -run 'HubSync'`
|
||||
- `cd /projects/Charon/backend && go test -cover ./internal/api/handlers -run 'Auth|CrowdSec|User|Import'`
|
||||
- `cd /projects/Charon/backend && go test -cover ./internal/cerberus -run 'RateLimit'`
|
||||
|
||||
3. Full backend coverage re-run after each wave:
|
||||
- `scripts/go-test-coverage.sh`
|
||||
|
||||
4. Patch verification loop:
|
||||
- Re-open Codecov Patch view.
|
||||
- Replace remaining placeholders with exact unresolved ranges.
|
||||
- Add next targeted tests for those exact ranges.
|
||||
- Re-run Step 2 and Step 3 until all patch ranges are covered.
|
||||
|
||||
5. Final validation:
|
||||
- `cd /projects/Charon/backend && go test ./...`
|
||||
- `scripts/go-test-coverage.sh`
|
||||
- Confirm Codecov patch coverage for backend modified lines reaches 100%.
|
||||
|
||||
### 12.6 Acceptance Criteria (Backend Remediation Section)
|
||||
### Supervisor Blocker 1: Correct Codecov line references
|
||||
|
||||
- Patch-triage table is fully populated with exact Codecov ranges (no placeholders left).
|
||||
- Zero-coverage files (`mail_service.go`, `hub_sync.go`, `auth_handler.go`, `backup_service.go`) are covered first.
|
||||
- Each file has tests for happy path, error path, and boundary/partial branches.
|
||||
- Concrete run sequence executed: baseline -> targeted go test -> coverage re-run -> patch verify loop.
|
||||
- Existing frontend unit-test coverage plan remains unchanged and intact.
|
||||
- No E2E requirement is introduced in this backend remediation section.
|
||||
Use actual line numbers from `codecov.yml`:
|
||||
- Entry-point ignore lines are `90-92`:
|
||||
- `90`: `backend/cmd/api/**`
|
||||
- `91`: `backend/cmd/seed/**`
|
||||
- `92`: `frontend/src/main.tsx`
|
||||
|
||||
### Supervisor Blocker 2: Keep Docker exclusions by default
|
||||
|
||||
Do **not** remove these ignore entries in this plan:
|
||||
- `backend/internal/services/docker_service.go` (line `105`)
|
||||
- `backend/internal/api/handlers/docker_handler.go` (line `106`)
|
||||
|
||||
Rationale:
|
||||
- They require Docker socket/runtime conditions and are not deterministic in
|
||||
standard CI unit coverage execution.
|
||||
- No explicit deterministic CI-testable strategy is introduced in this plan.
|
||||
|
||||
### Supervisor Blocker 3: Remove ambiguity and legacy scope
|
||||
|
||||
This document is now authoritative for only the current objective and excludes
|
||||
legacy/additive planning content.
|
||||
|
||||
### Supervisor Blocker 4: Add explicit post-change verification checks
|
||||
|
||||
Post-change checks now require grep-style assertions proving integration/trace
|
||||
exclusions remain and coverage gates still pass.
|
||||
|
||||
## 3) Exact Planned Changes
|
||||
|
||||
### A. Frontend quarantine execution excludes (`frontend/vitest.config.ts`)
|
||||
|
||||
Remove only the temporary quarantine entries so tests execute again.
|
||||
|
||||
Keep existing generic exclusions unchanged (examples):
|
||||
- `node_modules/**`
|
||||
- `dist/**`
|
||||
- `e2e/**`
|
||||
- `tests/**`
|
||||
|
||||
### B. Backend script coverage exclusions (`scripts/go-test-coverage.sh`)
|
||||
|
||||
From `EXCLUDE_PACKAGES`, remove only:
|
||||
- `github.com/Wikid82/charon/backend/cmd/api`
|
||||
- `github.com/Wikid82/charon/backend/cmd/seed`
|
||||
|
||||
Keep unchanged:
|
||||
- `github.com/Wikid82/charon/backend/internal/trace`
|
||||
- `github.com/Wikid82/charon/backend/integration`
|
||||
|
||||
### C. Codecov ignores (`codecov.yml`)
|
||||
|
||||
Remove from ignore list:
|
||||
- `backend/cmd/api/**` (line `90`)
|
||||
- `backend/cmd/seed/**` (line `91`)
|
||||
- `frontend/src/main.tsx` (line `92`)
|
||||
|
||||
Keep unchanged:
|
||||
- `backend/internal/trace/**` (line `99`)
|
||||
- `backend/integration/**` (line `100`)
|
||||
- `backend/internal/services/docker_service.go` (line `105`)
|
||||
- `backend/internal/api/handlers/docker_handler.go` (line `106`)
|
||||
|
||||
## 4) Verification Plan (Mandatory)
|
||||
|
||||
### A. Static grep-style assertions for preserved exclusions
|
||||
|
||||
Run and expect matches:
|
||||
|
||||
```bash
|
||||
grep -n 'backend/internal/trace/\*\*' codecov.yml
|
||||
grep -n 'backend/integration/\*\*' codecov.yml
|
||||
grep -n 'backend/internal/services/docker_service.go' codecov.yml
|
||||
grep -n 'backend/internal/api/handlers/docker_handler.go' codecov.yml
|
||||
grep -n 'backend/internal/trace' scripts/go-test-coverage.sh
|
||||
grep -n 'backend/integration' scripts/go-test-coverage.sh
|
||||
```
|
||||
|
||||
Run and expect **no** matches for removed entries:
|
||||
|
||||
```bash
|
||||
grep -n 'backend/cmd/api/\*\*' codecov.yml
|
||||
grep -n 'backend/cmd/seed/\*\*' codecov.yml
|
||||
grep -n 'frontend/src/main.tsx' codecov.yml
|
||||
grep -n 'github.com/Wikid82/charon/backend/cmd/api' scripts/go-test-coverage.sh
|
||||
grep -n 'github.com/Wikid82/charon/backend/cmd/seed' scripts/go-test-coverage.sh
|
||||
```
|
||||
|
||||
### B. Coverage status gate checks
|
||||
|
||||
Required commands:
|
||||
|
||||
```bash
|
||||
.github/skills/scripts/skill-runner.sh test-frontend-unit
|
||||
.github/skills/scripts/skill-runner.sh test-frontend-coverage
|
||||
.github/skills/scripts/skill-runner.sh test-backend-unit
|
||||
.github/skills/scripts/skill-runner.sh test-backend-coverage
|
||||
```
|
||||
|
||||
Pass criteria:
|
||||
- Frontend and backend coverage gates remain green (project thresholds).
|
||||
- No regression caused by accidental integration/trace exclusion changes.
|
||||
|
||||
## 5) Acceptance Criteria
|
||||
|
||||
- Codecov entry-point line references in this plan are corrected to lines
|
||||
`90-92`.
|
||||
- Docker service/handler excludes remain in `codecov.yml`.
|
||||
- Integration and trace excludes remain unchanged in `codecov.yml` and
|
||||
`scripts/go-test-coverage.sh`.
|
||||
- Frontend quarantine execution excludes are removed as planned.
|
||||
- Selected unit-test code is included in coverage accounting as planned.
|
||||
- Grep-style verification checks are executed and documented.
|
||||
- Coverage status gates continue to pass.
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint . --report-unused-disable-directives",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run",
|
||||
"test": "NODE_OPTIONS=--max-old-space-size=4096 vitest run",
|
||||
"test:ci": "NODE_OPTIONS=--max-old-space-size=4096 vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"check-coverage": "bash ../scripts/frontend-test-coverage.sh",
|
||||
"pretest:coverage": "npm ci --silent && node -e \"require('fs').mkdirSync('coverage/.tmp', { recursive: true })\"",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage": "NODE_OPTIONS=--max-old-space-size=4096 vitest run --coverage",
|
||||
"e2e:install": "npx playwright install --with-deps",
|
||||
"e2e:test": "playwright test",
|
||||
"e2e:up:block": "docker compose -f ../.docker/compose/docker-compose.local.yml down && CHARON_SECURITY_WAF_MODE=block docker compose -f ../.docker/compose/docker-compose.local.yml up -d",
|
||||
|
||||
@@ -243,7 +243,9 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
}
|
||||
|
||||
const { servers: remoteServers } = useRemoteServers()
|
||||
const safeRemoteServers = Array.isArray(remoteServers) ? remoteServers : []
|
||||
const { domains, createDomain } = useDomains()
|
||||
const safeDomains = Array.isArray(domains) ? domains : []
|
||||
const { certificates } = useCertificates()
|
||||
const { data: securityProfiles } = useSecurityHeaderProfiles()
|
||||
|
||||
@@ -307,7 +309,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
if (parsed.domain && parsed.domain !== domain) {
|
||||
// It's a subdomain, check if the base domain exists
|
||||
const baseDomain = parsed.domain
|
||||
const exists = domains.some(d => d.name === baseDomain)
|
||||
const exists = safeDomains.some(d => d.name === baseDomain)
|
||||
if (!exists) {
|
||||
setPendingDomain(baseDomain)
|
||||
setShowDomainPrompt(true)
|
||||
@@ -315,7 +317,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
}
|
||||
} else if (parsed.domain && parsed.domain === domain) {
|
||||
// It is a base domain, check if it exists
|
||||
const exists = domains.some(d => d.name === domain)
|
||||
const exists = safeDomains.some(d => d.name === domain)
|
||||
if (!exists) {
|
||||
setPendingDomain(domain)
|
||||
setShowDomainPrompt(true)
|
||||
@@ -475,7 +477,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
|
||||
// If using a Remote Server, try to use the Host IP and Mapped Public Port
|
||||
if (connectionSource !== 'local' && connectionSource !== 'custom') {
|
||||
const server = remoteServers.find(s => s.uuid === connectionSource)
|
||||
const server = safeRemoteServers.find(s => s.uuid === connectionSource)
|
||||
if (server) {
|
||||
// Use the Remote Server's Host IP (e.g. public/tailscale IP)
|
||||
host = server.host
|
||||
@@ -603,7 +605,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
<SelectContent>
|
||||
<SelectItem value="custom">Custom / Manual</SelectItem>
|
||||
<SelectItem value="local">Local (Docker Socket)</SelectItem>
|
||||
{remoteServers
|
||||
{safeRemoteServers
|
||||
.filter(s => s.provider === 'docker' && s.enabled)
|
||||
.map(server => (
|
||||
<SelectItem key={server.uuid} value={server.uuid}>
|
||||
@@ -660,7 +662,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
|
||||
{/* Domain Names */}
|
||||
<div className="space-y-4">
|
||||
{domains.length > 0 && (
|
||||
{safeDomains.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Base Domain (Auto-fill)
|
||||
@@ -670,7 +672,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
<SelectValue placeholder="Select a base domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{domains.map(domain => (
|
||||
{safeDomains.map(domain => (
|
||||
<SelectItem key={domain.uuid} value={domain.name}>
|
||||
{domain.name}
|
||||
</SelectItem>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import Notifications from '../Notifications'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import * as notificationsApi from '../../api/notifications'
|
||||
@@ -131,10 +130,9 @@ describe('Notifications', () => {
|
||||
})
|
||||
|
||||
it('shows and hides the update indicator after save', async () => {
|
||||
vi.useFakeTimers()
|
||||
setupMocks([baseProvider])
|
||||
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
const row = await screen.findByTestId(`provider-row-${baseProvider.id}`)
|
||||
@@ -143,28 +141,24 @@ describe('Notifications', () => {
|
||||
|
||||
await user.click(screen.getByTestId('provider-save-btn'))
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
expect(notificationsApi.updateProvider).toHaveBeenCalled()
|
||||
|
||||
expect(screen.getByTestId(`provider-update-indicator-${baseProvider.id}`)).toBeInTheDocument()
|
||||
expect(toast.success).toHaveBeenCalledWith('common.saved')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId(`provider-update-indicator-${baseProvider.id}`)).toBeNull()
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByTestId(`provider-update-indicator-${baseProvider.id}`)).toBeNull()
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans up the update indicator timer on unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
setupMocks([baseProvider])
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = renderWithQueryClient(<Notifications />)
|
||||
|
||||
const row = await screen.findByTestId(`provider-row-${baseProvider.id}`)
|
||||
@@ -173,10 +167,6 @@ describe('Notifications', () => {
|
||||
|
||||
await user.click(screen.getByTestId('provider-save-btn'))
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
expect(notificationsApi.updateProvider).toHaveBeenCalled()
|
||||
expect(screen.getByTestId(`provider-update-indicator-${baseProvider.id}`)).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('ProxyHosts page extra tests', () => {
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('DelHost')).toBeInTheDocument())
|
||||
const deleteBtn = screen.getByRole('button', { name: 'Delete' })
|
||||
const deleteBtn = screen.getByRole('button', { name: 'Delete proxy host DelHost' })
|
||||
await userEvent.click(deleteBtn)
|
||||
|
||||
// Confirm deletion in the dialog
|
||||
|
||||
@@ -36,78 +36,79 @@ vi.mock('../../hooks/useNotifications', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
const securityTranslations: Record<string, string> = {
|
||||
'security.title': 'Security',
|
||||
'security.description': 'Configure security layers for your reverse proxy',
|
||||
'security.cerberusDashboard': 'Cerberus Dashboard',
|
||||
'security.cerberusActive': 'Active',
|
||||
'security.cerberusDisabled': 'Disabled',
|
||||
'security.cerberusReadyMessage': 'Cerberus is ready to protect your services',
|
||||
'security.cerberusDisabledMessage': 'Enable Cerberus in System Settings to activate security features',
|
||||
'security.featuresUnavailable': 'Security Features Unavailable',
|
||||
'security.featuresUnavailableMessage': 'Enable Cerberus in System Settings to use security features',
|
||||
'security.learnMore': 'Learn More',
|
||||
'security.adminWhitelist': 'Admin Whitelist',
|
||||
'security.adminWhitelistDescription': 'CIDRs that bypass security checks for admin access',
|
||||
'security.commaSeparatedCIDR': 'Comma-separated CIDRs (e.g., 192.168.1.0/24)',
|
||||
'security.generateToken': 'Generate Token',
|
||||
'security.generateTokenTooltip': 'Generate a one-time break-glass token for emergency access',
|
||||
'security.layer1': 'Layer 1',
|
||||
'security.layer2': 'Layer 2',
|
||||
'security.layer3': 'Layer 3',
|
||||
'security.layer4': 'Layer 4',
|
||||
'security.ids': 'IDS',
|
||||
'security.acl': 'ACL',
|
||||
'security.waf': 'WAF',
|
||||
'security.rate': 'Rate',
|
||||
'security.crowdsec': 'CrowdSec',
|
||||
'security.crowdsecDescription': 'IP Reputation',
|
||||
'security.crowdsecProtects': 'Blocks known attackers, botnets, and malicious IPs',
|
||||
'security.crowdsecDisabledDescription': 'Enable to block known malicious IPs',
|
||||
'security.accessControl': 'Access Control',
|
||||
'security.aclDescription': 'IP Allowlists/Blocklists',
|
||||
'security.aclProtects': 'Unauthorized IPs, geo-based attacks',
|
||||
'security.corazaWaf': 'Coraza WAF',
|
||||
'security.wafDescription': 'Request Inspection',
|
||||
'security.wafProtects': 'SQL injection, XSS, RCE',
|
||||
'security.wafDisabledDescription': 'Enable to inspect requests for threats',
|
||||
'security.rateLimiting': 'Rate Limiting',
|
||||
'security.rateLimitDescription': 'Volume Control',
|
||||
'security.rateLimitProtects': 'DDoS attacks, credential stuffing',
|
||||
'security.processStopped': 'Process stopped',
|
||||
'security.enableCerberusFirst': 'Enable Cerberus first',
|
||||
'security.toggleCrowdsec': 'Toggle CrowdSec',
|
||||
'security.toggleAcl': 'Toggle Access Control',
|
||||
'security.toggleWaf': 'Toggle WAF',
|
||||
'security.toggleRateLimit': 'Toggle Rate Limiting',
|
||||
'security.manageLists': 'Manage Lists',
|
||||
'security.auditLogs': 'Audit Logs',
|
||||
'security.notifications': 'Notifications',
|
||||
'security.threeHeadsTurn': 'Three heads turn',
|
||||
'security.cerberusConfigUpdating': 'Cerberus configuration updating',
|
||||
'security.summoningGuardian': 'Summoning the guardian',
|
||||
'security.crowdsecStarting': 'CrowdSec is starting',
|
||||
'security.guardianRests': 'Guardian rests',
|
||||
'security.crowdsecStopping': 'CrowdSec is stopping',
|
||||
'security.strengtheningGuard': 'Strengthening guard',
|
||||
'security.wardsActivating': 'Wards activating',
|
||||
'common.enabled': 'Enabled',
|
||||
'common.disabled': 'Disabled',
|
||||
'common.save': 'Save',
|
||||
'common.configure': 'Configure',
|
||||
'common.docs': 'Docs',
|
||||
'common.error': 'Error',
|
||||
'security.failedToLoadConfiguration': 'Failed to load security configuration',
|
||||
}
|
||||
|
||||
// Mock i18n translation
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { pid?: number }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'security.title': 'Security',
|
||||
'security.description': 'Configure security layers for your reverse proxy',
|
||||
'security.cerberusDashboard': 'Cerberus Dashboard',
|
||||
'security.cerberusActive': 'Active',
|
||||
'security.cerberusDisabled': 'Disabled',
|
||||
'security.cerberusReadyMessage': 'Cerberus is ready to protect your services',
|
||||
'security.cerberusDisabledMessage': 'Enable Cerberus in System Settings to activate security features',
|
||||
'security.featuresUnavailable': 'Security Features Unavailable',
|
||||
'security.featuresUnavailableMessage': 'Enable Cerberus in System Settings to use security features',
|
||||
'security.learnMore': 'Learn More',
|
||||
'security.adminWhitelist': 'Admin Whitelist',
|
||||
'security.adminWhitelistDescription': 'CIDRs that bypass security checks for admin access',
|
||||
'security.commaSeparatedCIDR': 'Comma-separated CIDRs (e.g., 192.168.1.0/24)',
|
||||
'security.generateToken': 'Generate Token',
|
||||
'security.generateTokenTooltip': 'Generate a one-time break-glass token for emergency access',
|
||||
'security.layer1': 'Layer 1',
|
||||
'security.layer2': 'Layer 2',
|
||||
'security.layer3': 'Layer 3',
|
||||
'security.layer4': 'Layer 4',
|
||||
'security.ids': 'IDS',
|
||||
'security.acl': 'ACL',
|
||||
'security.waf': 'WAF',
|
||||
'security.rate': 'Rate',
|
||||
'security.crowdsec': 'CrowdSec',
|
||||
'security.crowdsecDescription': 'IP Reputation',
|
||||
'security.crowdsecProtects': 'Blocks known attackers, botnets, and malicious IPs',
|
||||
'security.crowdsecDisabledDescription': 'Enable to block known malicious IPs',
|
||||
'security.accessControl': 'Access Control',
|
||||
'security.aclDescription': 'IP Allowlists/Blocklists',
|
||||
'security.aclProtects': 'Unauthorized IPs, geo-based attacks',
|
||||
'security.corazaWaf': 'Coraza WAF',
|
||||
'security.wafDescription': 'Request Inspection',
|
||||
'security.wafProtects': 'SQL injection, XSS, RCE',
|
||||
'security.wafDisabledDescription': 'Enable to inspect requests for threats',
|
||||
'security.rateLimiting': 'Rate Limiting',
|
||||
'security.rateLimitDescription': 'Volume Control',
|
||||
'security.rateLimitProtects': 'DDoS attacks, credential stuffing',
|
||||
'security.processStopped': 'Process stopped',
|
||||
'security.enableCerberusFirst': 'Enable Cerberus first',
|
||||
'security.toggleCrowdsec': 'Toggle CrowdSec',
|
||||
'security.toggleAcl': 'Toggle Access Control',
|
||||
'security.toggleWaf': 'Toggle WAF',
|
||||
'security.toggleRateLimit': 'Toggle Rate Limiting',
|
||||
'security.manageLists': 'Manage Lists',
|
||||
'security.auditLogs': 'Audit Logs',
|
||||
'security.notifications': 'Notifications',
|
||||
'security.threeHeadsTurn': 'Three heads turn',
|
||||
'security.cerberusConfigUpdating': 'Cerberus configuration updating',
|
||||
'security.summoningGuardian': 'Summoning the guardian',
|
||||
'security.crowdsecStarting': 'CrowdSec is starting',
|
||||
'security.guardianRests': 'Guardian rests',
|
||||
'security.crowdsecStopping': 'CrowdSec is stopping',
|
||||
'security.strengtheningGuard': 'Strengthening guard',
|
||||
'security.wardsActivating': 'Wards activating',
|
||||
'common.enabled': 'Enabled',
|
||||
'common.disabled': 'Disabled',
|
||||
'common.save': 'Save',
|
||||
'common.configure': 'Configure',
|
||||
'common.docs': 'Docs',
|
||||
'common.error': 'Error',
|
||||
'security.failedToLoadConfiguration': 'Failed to load security configuration',
|
||||
}
|
||||
// Handle interpolation for runningPid
|
||||
if (key === 'security.runningPid' && options?.pid !== undefined) {
|
||||
return `Running (pid ${options.pid})`
|
||||
}
|
||||
return translations[key] || key
|
||||
return securityTranslations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
@@ -117,17 +118,21 @@ vi.mock('../../components/LiveLogViewer', () => ({
|
||||
LiveLogViewer: () => <div data-testid="live-log-viewer">Mocked Live Log Viewer</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../components/SecurityNotificationSettingsModal', () => ({
|
||||
SecurityNotificationSettingsModal: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../../components/CrowdSecKeyWarning', () => ({
|
||||
CrowdSecKeyWarning: () => null,
|
||||
}))
|
||||
|
||||
// NOTE: CrowdSecBouncerKeyDisplay mock removed (moved to CrowdSecConfig page)
|
||||
|
||||
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
||||
return {
|
||||
...actual,
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
}
|
||||
})
|
||||
vi.mock('../../hooks/useSecurity', () => ({
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
}))
|
||||
|
||||
const mockSecurityStatusAllEnabled = {
|
||||
cerberus: { enabled: true },
|
||||
@@ -166,7 +171,6 @@ describe('Security Page - Functional Tests', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
@@ -183,12 +187,25 @@ describe('Security Page - Functional Tests', () => {
|
||||
|
||||
describe('Page Loading States', () => {
|
||||
it('should show skeleton loading state initially', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
|
||||
const deferredStatus: { resolve: (value: typeof mockSecurityStatusAllEnabled) => void } = {
|
||||
resolve: () => {
|
||||
throw new Error('Test setup failed: pending status resolver was not initialized')
|
||||
},
|
||||
}
|
||||
const pendingStatus = new Promise<typeof mockSecurityStatusAllEnabled>((resolve) => {
|
||||
deferredStatus.resolve = resolve
|
||||
})
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(pendingStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
|
||||
deferredStatus.resolve(mockSecurityStatusAllEnabled)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display error message when security status fails to load', async () => {
|
||||
|
||||
@@ -128,6 +128,7 @@ console.error = (...args: unknown[]) => {
|
||||
if (typeof msg === 'string') {
|
||||
if (
|
||||
msg.includes("The current testing environment is not configured to support act(") ||
|
||||
msg.includes('not wrapped in act(') ||
|
||||
msg.includes('Test connection failed') ||
|
||||
msg.includes('Connection failed')
|
||||
) {
|
||||
|
||||
@@ -11,11 +11,8 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
pool: 'forks',
|
||||
poolOptions: {
|
||||
forks: {
|
||||
memoryLimit: '512MB',
|
||||
},
|
||||
},
|
||||
maxWorkers: 1,
|
||||
minWorkers: 1,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
environmentOptions: {
|
||||
@@ -36,6 +33,7 @@ export default defineConfig({
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
clean: false,
|
||||
reporter: ['text', 'json', 'html', 'lcov', 'json-summary'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
|
||||
Reference in New Issue
Block a user