diff --git a/backend/cmd/api/main_parse_plugin_signatures_test.go b/backend/cmd/api/main_parse_plugin_signatures_test.go new file mode 100644 index 00000000..4f54fb2c --- /dev/null +++ b/backend/cmd/api/main_parse_plugin_signatures_test.go @@ -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) + } + }) +} diff --git a/backend/cmd/api/main_test.go b/backend/cmd/api/main_test.go index b0745c44..17059b38 100644 --- a/backend/cmd/api/main_test.go +++ b/backend/cmd/api/main_test.go @@ -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) + } +} diff --git a/backend/cmd/seed/seed_smoke_test.go b/backend/cmd/seed/seed_smoke_test.go index bfd6288d..676150c9 100644 --- a/backend/cmd/seed/seed_smoke_test.go +++ b/backend/cmd/seed/seed_smoke_test.go @@ -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") + } + }) +} diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 909a6c4a..afe25a03 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -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")) diff --git a/backend/internal/api/handlers/backup_handler_test.go b/backend/internal/api/handlers/backup_handler_test.go index 967a8f21..8a1556bc 100644 --- a/backend/internal/api/handlers/backup_handler_test.go +++ b/backend/internal/api/handlers/backup_handler_test.go @@ -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() diff --git a/backend/internal/services/auth_service_test.go b/backend/internal/services/auth_service_test.go index f2ca9475..ccb375fd 100644 --- a/backend/internal/services/auth_service_test.go +++ b/backend/internal/services/auth_service_test.go @@ -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()) +} diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 99c95140..fe7f9c23 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -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}}}]}`, } diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 42942c1d..c07c8b0d 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -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-L`, `L-L` | `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-L`; Partial: `L-L` | `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-L`, `L-L` | `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-L`; Partial: `L-L` | `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-L`; Partial: `L-L` | `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-L`; Partial: `L-L` | `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-L`; Partial: `L-L` | `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-L`; Partial: `L-L` | `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-L`; Partial: `L-L` | `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-L`; Partial: `L-L` | `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. diff --git a/frontend/package.json b/frontend/package.json index f3743afa..bfeaf490 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index d055a295..86eee761 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -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 Custom / Manual Local (Docker Socket) - {remoteServers + {safeRemoteServers .filter(s => s.provider === 'docker' && s.enabled) .map(server => ( @@ -660,7 +662,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor {/* Domain Names */}
- {domains.length > 0 && ( + {safeDomains.length > 0 && (