chore: clean .gitignore cache
This commit is contained in:
@@ -1,983 +0,0 @@
|
||||
# Test Coverage Remediation Plan
|
||||
|
||||
**Date:** January 3, 2026
|
||||
**Current Patch Coverage:** 84.85%
|
||||
**Target:** ≥85%
|
||||
**Missing Lines:** 134 total
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This plan details the specific test cases needed to increase patch coverage from 84.85% to 85%+. The analysis identified uncovered code paths in 10 files and provides implementation-ready test specifications for Backend_Dev and Frontend_Dev agents.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Quick Wins (Estimated +22-24 lines)
|
||||
|
||||
### 1.1 `backend/internal/network/internal_service_client.go` — 0% → 100%
|
||||
|
||||
**Test File:** `backend/internal/network/internal_service_client_test.go` (CREATE NEW)
|
||||
|
||||
**Uncovered:** Entire file (14 lines)
|
||||
|
||||
```go
|
||||
package network
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewInternalServiceHTTPClient_CreatesClientWithCorrectTimeout(t *testing.T) {
|
||||
timeout := 5 * time.Second
|
||||
client := NewInternalServiceHTTPClient(timeout)
|
||||
|
||||
require.NotNil(t, client)
|
||||
assert.Equal(t, timeout, client.Timeout)
|
||||
}
|
||||
|
||||
func TestNewInternalServiceHTTPClient_TransportSettings(t *testing.T) {
|
||||
client := NewInternalServiceHTTPClient(10 * time.Second)
|
||||
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
require.True(t, ok, "Transport should be *http.Transport")
|
||||
|
||||
// Verify SSRF-safe settings
|
||||
assert.Nil(t, transport.Proxy, "Proxy should be nil to ignore env vars")
|
||||
assert.True(t, transport.DisableKeepAlives, "KeepAlives should be disabled")
|
||||
assert.Equal(t, 1, transport.MaxIdleConns)
|
||||
}
|
||||
|
||||
func TestNewInternalServiceHTTPClient_DisablesRedirects(t *testing.T) {
|
||||
// Server that redirects
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
http.Redirect(w, r, "/other", http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewInternalServiceHTTPClient(5 * time.Second)
|
||||
resp, err := client.Get(server.URL)
|
||||
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should NOT follow redirect - returns the redirect response directly
|
||||
assert.Equal(t, http.StatusFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestNewInternalServiceHTTPClient_RespectsTimeout(t *testing.T) {
|
||||
// Server that delays response
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Very short timeout
|
||||
client := NewInternalServiceHTTPClient(50 * time.Millisecond)
|
||||
_, err := client.Get(server.URL)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "timeout")
|
||||
}
|
||||
```
|
||||
|
||||
**Coverage Gain:** +14 lines
|
||||
|
||||
---
|
||||
|
||||
### 1.2 `backend/internal/crypto/encryption.go` — 74.35% → ~90%
|
||||
|
||||
**Test File:** `backend/internal/crypto/encryption_test.go` (EXTEND)
|
||||
|
||||
**Uncovered Code Paths:**
|
||||
|
||||
- Lines 35-37: `aes.NewCipher` error (difficult to trigger)
|
||||
- Lines 38-40: `cipher.NewGCM` error (difficult to trigger)
|
||||
- Lines 43-45: `io.ReadFull(rand.Reader, nonce)` error
|
||||
|
||||
```go
|
||||
// ADD to existing encryption_test.go
|
||||
|
||||
func TestEncrypt_NilPlaintext(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
_, _ = rand.Read(key)
|
||||
keyBase64 := base64.StdEncoding.EncodeToString(key)
|
||||
|
||||
svc, err := NewEncryptionService(keyBase64)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Encrypting nil should work (treated as empty)
|
||||
ciphertext, err := svc.Encrypt(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, ciphertext)
|
||||
|
||||
// Should decrypt to empty
|
||||
decrypted, err := svc.Decrypt(ciphertext)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, decrypted)
|
||||
}
|
||||
|
||||
func TestDecrypt_ExactlyNonceSizeBytes(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
_, _ = rand.Read(key)
|
||||
keyBase64 := base64.StdEncoding.EncodeToString(key)
|
||||
|
||||
svc, err := NewEncryptionService(keyBase64)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create ciphertext that is exactly nonce size (12 bytes for GCM)
|
||||
// This should fail because there's no actual ciphertext after the nonce
|
||||
shortCiphertext := base64.StdEncoding.EncodeToString(make([]byte, 12))
|
||||
|
||||
_, err = svc.Decrypt(shortCiphertext)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "decryption failed")
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_LargeData(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
_, _ = rand.Read(key)
|
||||
keyBase64 := base64.StdEncoding.EncodeToString(key)
|
||||
|
||||
svc, err := NewEncryptionService(keyBase64)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test with 1MB of data
|
||||
largeData := make([]byte, 1024*1024)
|
||||
_, _ = rand.Read(largeData)
|
||||
|
||||
ciphertext, err := svc.Encrypt(largeData)
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted, err := svc.Decrypt(ciphertext)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, largeData, decrypted)
|
||||
}
|
||||
```
|
||||
|
||||
**Coverage Gain:** +8-10 lines
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: High Impact (Estimated +30-38 lines)
|
||||
|
||||
### 2.1 `backend/internal/utils/url_testing.go` — 74.83% → ~90%
|
||||
|
||||
**Test File:** `backend/internal/utils/url_testing_coverage_test.go` (CREATE NEW)
|
||||
|
||||
**Uncovered Code Paths:**
|
||||
|
||||
1. `resolveAllowedIP`: IP literal localhost allowed path
|
||||
2. `resolveAllowedIP`: DNS returning empty IPs
|
||||
3. `resolveAllowedIP`: Multiple IPs with first being loopback
|
||||
4. `testURLConnectivity`: Error message transformations
|
||||
5. `testURLConnectivity`: Port validation paths
|
||||
6. `validateRedirectTargetStrict`: Scheme downgrade blocking
|
||||
7. `validateRedirectTargetStrict`: Max redirects exceeded
|
||||
|
||||
```go
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ============== resolveAllowedIP Coverage ==============
|
||||
|
||||
func TestResolveAllowedIP_IPLiteralLocalhostAllowed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// With allowLocalhost=true, loopback should be allowed
|
||||
ip, err := resolveAllowedIP(ctx, "127.0.0.1", true)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ip.IsLoopback())
|
||||
}
|
||||
|
||||
func TestResolveAllowedIP_EmptyHostname(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := resolveAllowedIP(ctx, "", false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "missing hostname")
|
||||
}
|
||||
|
||||
func TestResolveAllowedIP_PrivateIPBlocked(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// IP literal in private range
|
||||
_, err := resolveAllowedIP(ctx, "192.168.1.1", false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "private IP")
|
||||
}
|
||||
|
||||
func TestResolveAllowedIP_PublicIPAllowed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Public IP literal
|
||||
ip, err := resolveAllowedIP(ctx, "8.8.8.8", false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "8.8.8.8", ip.String())
|
||||
}
|
||||
|
||||
// ============== validateRedirectTargetStrict Coverage ==============
|
||||
|
||||
func TestValidateRedirectTarget_SchemeDowngradeBlocked(t *testing.T) {
|
||||
// Previous request was HTTPS
|
||||
prevReq, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
|
||||
// New request is HTTP (downgrade)
|
||||
newReq, _ := http.NewRequest(http.MethodGet, "http://example.com/path", nil)
|
||||
|
||||
err := validateRedirectTargetStrict(newReq, []*http.Request{prevReq}, 5, false, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "scheme change blocked")
|
||||
}
|
||||
|
||||
func TestValidateRedirectTarget_HTTPSUpgradeAllowed(t *testing.T) {
|
||||
// Previous request was HTTP
|
||||
prevReq, _ := http.NewRequest(http.MethodGet, "http://localhost", nil)
|
||||
|
||||
// New request is HTTPS (upgrade) - should be allowed when allowHTTPSUpgrade=true
|
||||
newReq, _ := http.NewRequest(http.MethodGet, "https://localhost/path", nil)
|
||||
|
||||
err := validateRedirectTargetStrict(newReq, []*http.Request{prevReq}, 5, true, true)
|
||||
// May fail on security validation, but not on scheme change
|
||||
if err != nil {
|
||||
assert.NotContains(t, err.Error(), "scheme change blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRedirectTarget_MaxRedirectsExceeded(t *testing.T) {
|
||||
// Create via slice with maxRedirects entries
|
||||
via := make([]*http.Request, 3)
|
||||
for i := range via {
|
||||
via[i], _ = http.NewRequest(http.MethodGet, "http://example.com", nil)
|
||||
}
|
||||
|
||||
newReq, _ := http.NewRequest(http.MethodGet, "http://example.com/final", nil)
|
||||
|
||||
err := validateRedirectTargetStrict(newReq, via, 3, true, true)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "too many redirects")
|
||||
}
|
||||
|
||||
// ============== testURLConnectivity Coverage ==============
|
||||
|
||||
func TestURLConnectivity_InvalidPortNumber(t *testing.T) {
|
||||
// Port 0 should be rejected
|
||||
reachable, _, err := testURLConnectivity(
|
||||
"https://example.com:0/path",
|
||||
withAllowLocalhostForTesting(),
|
||||
)
|
||||
require.Error(t, err)
|
||||
assert.False(t, reachable)
|
||||
}
|
||||
|
||||
func TestURLConnectivity_PortOutOfRange(t *testing.T) {
|
||||
// Port > 65535 should be rejected
|
||||
reachable, _, err := testURLConnectivity(
|
||||
"https://example.com:70000/path",
|
||||
withAllowLocalhostForTesting(),
|
||||
)
|
||||
require.Error(t, err)
|
||||
assert.False(t, reachable)
|
||||
assert.Contains(t, err.Error(), "invalid")
|
||||
}
|
||||
|
||||
func TestURLConnectivity_ServerError5xx(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
reachable, latency, err := testURLConnectivity(
|
||||
server.URL,
|
||||
withAllowLocalhostForTesting(),
|
||||
withTransportForTesting(server.Client().Transport),
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.False(t, reachable)
|
||||
assert.Greater(t, latency, float64(0))
|
||||
assert.Contains(t, err.Error(), "status 500")
|
||||
}
|
||||
```
|
||||
|
||||
**Coverage Gain:** +15-20 lines
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `backend/internal/services/dns_provider_service.go` — 78.26% → ~90%
|
||||
|
||||
**Test File:** `backend/internal/services/dns_provider_service_test.go` (EXTEND)
|
||||
|
||||
**Uncovered Code Paths:**
|
||||
|
||||
1. `Create`: DB error during default provider update
|
||||
2. `Update`: Explicit IsDefault=false unsetting
|
||||
3. `Update`: DB error during save
|
||||
4. `Test`: Decryption failure path (already tested, verify)
|
||||
5. `testDNSProviderCredentials`: Validation failure
|
||||
|
||||
```go
|
||||
// ADD to existing dns_provider_service_test.go
|
||||
|
||||
func TestDNSProviderService_Update_ExplicitUnsetDefault(t *testing.T) {
|
||||
db, encryptor := setupDNSProviderTestDB(t)
|
||||
service := NewDNSProviderService(db, encryptor)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create provider as default
|
||||
provider, err := service.Create(ctx, CreateDNSProviderRequest{
|
||||
Name: "Default Provider",
|
||||
ProviderType: "cloudflare",
|
||||
Credentials: map[string]string{"api_token": "token"},
|
||||
IsDefault: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, provider.IsDefault)
|
||||
|
||||
// Explicitly unset default
|
||||
notDefault := false
|
||||
updated, err := service.Update(ctx, provider.ID, UpdateDNSProviderRequest{
|
||||
IsDefault: ¬Default,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, updated.IsDefault)
|
||||
}
|
||||
|
||||
func TestDNSProviderService_Update_AllFieldsAtOnce(t *testing.T) {
|
||||
db, encryptor := setupDNSProviderTestDB(t)
|
||||
service := NewDNSProviderService(db, encryptor)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create initial provider
|
||||
provider, err := service.Create(ctx, CreateDNSProviderRequest{
|
||||
Name: "Original",
|
||||
ProviderType: "cloudflare",
|
||||
Credentials: map[string]string{"api_token": "original"},
|
||||
PropagationTimeout: 60,
|
||||
PollingInterval: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update all fields at once
|
||||
newName := "Updated Name"
|
||||
newTimeout := 180
|
||||
newInterval := 10
|
||||
newEnabled := false
|
||||
newDefault := true
|
||||
newCreds := map[string]string{"api_token": "new-token"}
|
||||
|
||||
updated, err := service.Update(ctx, provider.ID, UpdateDNSProviderRequest{
|
||||
Name: &newName,
|
||||
PropagationTimeout: &newTimeout,
|
||||
PollingInterval: &newInterval,
|
||||
Enabled: &newEnabled,
|
||||
IsDefault: &newDefault,
|
||||
Credentials: newCreds,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Updated Name", updated.Name)
|
||||
assert.Equal(t, 180, updated.PropagationTimeout)
|
||||
assert.Equal(t, 10, updated.PollingInterval)
|
||||
assert.False(t, updated.Enabled)
|
||||
assert.True(t, updated.IsDefault)
|
||||
}
|
||||
|
||||
func TestDNSProviderService_Test_UpdatesFailureStatistics(t *testing.T) {
|
||||
db, encryptor := setupDNSProviderTestDB(t)
|
||||
service := NewDNSProviderService(db, encryptor)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create provider with invalid encrypted credentials
|
||||
provider := &models.DNSProvider{
|
||||
UUID: "test-uuid",
|
||||
Name: "Test",
|
||||
ProviderType: "cloudflare",
|
||||
CredentialsEncrypted: "invalid-ciphertext",
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(provider).Error)
|
||||
|
||||
// Test should fail decryption and update failure statistics
|
||||
result, err := service.Test(ctx, provider.ID)
|
||||
require.NoError(t, err) // No error returned, but result indicates failure
|
||||
assert.False(t, result.Success)
|
||||
assert.Equal(t, "DECRYPTION_ERROR", result.Code)
|
||||
|
||||
// Verify failure count was incremented
|
||||
var updatedProvider models.DNSProvider
|
||||
require.NoError(t, db.First(&updatedProvider, provider.ID).Error)
|
||||
assert.Equal(t, 1, updatedProvider.FailureCount)
|
||||
assert.NotEmpty(t, updatedProvider.LastError)
|
||||
}
|
||||
|
||||
func TestTestDNSProviderCredentials_MissingField(t *testing.T) {
|
||||
// Test with missing required field
|
||||
result := testDNSProviderCredentials("route53", map[string]string{
|
||||
"access_key_id": "key",
|
||||
// Missing secret_access_key and region
|
||||
})
|
||||
|
||||
assert.False(t, result.Success)
|
||||
assert.Equal(t, "VALIDATION_ERROR", result.Code)
|
||||
assert.Contains(t, result.Error, "missing")
|
||||
}
|
||||
|
||||
func TestDNSProviderService_Create_SetsDefaults(t *testing.T) {
|
||||
db, encryptor := setupDNSProviderTestDB(t)
|
||||
service := NewDNSProviderService(db, encryptor)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create without specifying timeout/interval
|
||||
provider, err := service.Create(ctx, CreateDNSProviderRequest{
|
||||
Name: "Default Test",
|
||||
ProviderType: "cloudflare",
|
||||
Credentials: map[string]string{"api_token": "token"},
|
||||
// PropagationTimeout and PollingInterval not set
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have default values
|
||||
assert.Equal(t, 120, provider.PropagationTimeout)
|
||||
assert.Equal(t, 5, provider.PollingInterval)
|
||||
assert.True(t, provider.Enabled) // Default enabled
|
||||
}
|
||||
|
||||
func TestDNSProviderService_GetDecryptedCredentials_UpdatesLastUsed(t *testing.T) {
|
||||
db, encryptor := setupDNSProviderTestDB(t)
|
||||
service := NewDNSProviderService(db, encryptor)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create provider
|
||||
provider, err := service.Create(ctx, CreateDNSProviderRequest{
|
||||
Name: "Test",
|
||||
ProviderType: "cloudflare",
|
||||
Credentials: map[string]string{"api_token": "token"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initially no last_used_at
|
||||
assert.Nil(t, provider.LastUsedAt)
|
||||
|
||||
// Get decrypted credentials
|
||||
_, err = service.GetDecryptedCredentials(ctx, provider.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify last_used_at was updated
|
||||
var updatedProvider models.DNSProvider
|
||||
require.NoError(t, db.First(&updatedProvider, provider.ID).Error)
|
||||
assert.NotNil(t, updatedProvider.LastUsedAt)
|
||||
}
|
||||
```
|
||||
|
||||
**Coverage Gain:** +15-18 lines
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Medium Impact (Estimated +14-18 lines)
|
||||
|
||||
### 3.1 `backend/internal/security/url_validator.go` — 77.55% → ~88%
|
||||
|
||||
**Test File:** `backend/internal/security/url_validator_test.go` (EXTEND)
|
||||
|
||||
**Uncovered Code Paths:**
|
||||
|
||||
1. `ValidateInternalServiceBaseURL`: All error paths
|
||||
2. `ParseExactHostnameAllowlist`: Invalid hostname filtering
|
||||
|
||||
```go
|
||||
// ADD to existing url_validator_test.go or internal_service_url_validator_test.go
|
||||
|
||||
func TestValidateInternalServiceBaseURL_AllErrorPaths(t *testing.T) {
|
||||
allowedHosts := map[string]struct{}{
|
||||
"localhost": {},
|
||||
"127.0.0.1": {},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
port int
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "invalid URL format",
|
||||
url: "://invalid",
|
||||
port: 8080,
|
||||
errContains: "invalid url format",
|
||||
},
|
||||
{
|
||||
name: "unsupported scheme",
|
||||
url: "ftp://localhost:8080",
|
||||
port: 8080,
|
||||
errContains: "unsupported scheme",
|
||||
},
|
||||
{
|
||||
name: "embedded credentials",
|
||||
url: "http://user:pass@localhost:8080",
|
||||
port: 8080,
|
||||
errContains: "embedded credentials",
|
||||
},
|
||||
{
|
||||
name: "missing hostname",
|
||||
url: "http:///path",
|
||||
port: 8080,
|
||||
errContains: "missing hostname",
|
||||
},
|
||||
{
|
||||
name: "hostname not allowed",
|
||||
url: "http://evil.com:8080",
|
||||
port: 8080,
|
||||
errContains: "hostname not allowed",
|
||||
},
|
||||
{
|
||||
name: "invalid port",
|
||||
url: "http://localhost:abc",
|
||||
port: 8080,
|
||||
errContains: "invalid port",
|
||||
},
|
||||
{
|
||||
name: "port mismatch",
|
||||
url: "http://localhost:9090",
|
||||
port: 8080,
|
||||
errContains: "unexpected port",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ValidateInternalServiceBaseURL(tt.url, tt.port, allowedHosts)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, strings.ToLower(err.Error()), tt.errContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInternalServiceBaseURL_Success(t *testing.T) {
|
||||
allowedHosts := map[string]struct{}{
|
||||
"localhost": {},
|
||||
"crowdsec": {},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
port int
|
||||
}{
|
||||
{"HTTP localhost", "http://localhost:8080", 8080},
|
||||
{"HTTPS localhost", "https://localhost:443", 443},
|
||||
{"Service name", "http://crowdsec:8085", 8085},
|
||||
{"Default HTTP port", "http://localhost", 80},
|
||||
{"Default HTTPS port", "https://localhost", 443},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ValidateInternalServiceBaseURL(tt.url, tt.port, allowedHosts)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExactHostnameAllowlist_FiltersInvalidEntries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected map[string]struct{}
|
||||
}{
|
||||
{
|
||||
name: "valid entries",
|
||||
input: "localhost,crowdsec,caddy",
|
||||
expected: map[string]struct{}{
|
||||
"localhost": {},
|
||||
"crowdsec": {},
|
||||
"caddy": {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filters entries with scheme",
|
||||
input: "localhost,http://invalid,crowdsec",
|
||||
expected: map[string]struct{}{
|
||||
"localhost": {},
|
||||
"crowdsec": {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filters entries with @",
|
||||
input: "localhost,user@host,crowdsec",
|
||||
expected: map[string]struct{}{
|
||||
"localhost": {},
|
||||
"crowdsec": {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: map[string]struct{}{},
|
||||
},
|
||||
{
|
||||
name: "handles whitespace",
|
||||
input: " localhost , crowdsec ",
|
||||
expected: map[string]struct{}{
|
||||
"localhost": {},
|
||||
"crowdsec": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ParseExactHostnameAllowlist(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Coverage Gain:** +8-10 lines
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `backend/internal/services/notification_service.go` — 66.66% → ~82%
|
||||
|
||||
**Test File:** `backend/internal/services/notification_service_test.go` (CREATE OR EXTEND)
|
||||
|
||||
**Uncovered Code Paths:**
|
||||
|
||||
1. `sendJSONPayload`: Template size limit exceeded
|
||||
2. `sendJSONPayload`: Discord/Slack/Gotify validation
|
||||
3. `sendJSONPayload`: DNS resolution failure
|
||||
4. `SendExternal`: Event type filtering
|
||||
|
||||
```go
|
||||
// File: backend/internal/services/notification_service_test.go
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func setupNotificationTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.Notification{}, &models.NotificationProvider{}, &models.NotificationTemplate{}))
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSendJSONPayload_TemplateSizeExceeded(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
// Template larger than 10KB limit
|
||||
largeTemplate := strings.Repeat("x", 11*1024)
|
||||
provider := models.NotificationProvider{
|
||||
Name: "Test",
|
||||
Type: "webhook",
|
||||
URL: "https://example.com/webhook",
|
||||
Template: "custom",
|
||||
Config: largeTemplate,
|
||||
}
|
||||
|
||||
err := svc.sendJSONPayload(context.Background(), provider, map[string]any{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeds maximum limit")
|
||||
}
|
||||
|
||||
func TestSendJSONPayload_DiscordValidation(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
// Discord requires 'content' or 'embeds'
|
||||
provider := models.NotificationProvider{
|
||||
Name: "Discord",
|
||||
Type: "discord",
|
||||
URL: "https://discord.com/api/webhooks/123/abc",
|
||||
Template: "custom",
|
||||
Config: `{"message": "test"}`, // Missing 'content' or 'embeds'
|
||||
}
|
||||
|
||||
err := svc.sendJSONPayload(context.Background(), provider, map[string]any{
|
||||
"Message": "test",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "content")
|
||||
}
|
||||
|
||||
func TestSendJSONPayload_SlackValidation(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
// Slack requires 'text' or 'blocks'
|
||||
provider := models.NotificationProvider{
|
||||
Name: "Slack",
|
||||
Type: "slack",
|
||||
URL: "https://hooks.slack.com/services/T00/B00/xxx",
|
||||
Template: "custom",
|
||||
Config: `{"message": "test"}`, // Missing 'text' or 'blocks'
|
||||
}
|
||||
|
||||
err := svc.sendJSONPayload(context.Background(), provider, map[string]any{
|
||||
"Message": "test",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "text")
|
||||
}
|
||||
|
||||
func TestSendExternal_EventTypeFiltering(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
// Create provider that only notifies on 'uptime' events
|
||||
provider := &models.NotificationProvider{
|
||||
Name: "Uptime Only",
|
||||
Type: "webhook",
|
||||
URL: "https://example.com/webhook",
|
||||
Enabled: true,
|
||||
NotifyUptime: true,
|
||||
NotifyDomains: false,
|
||||
NotifyCerts: false,
|
||||
}
|
||||
require.NoError(t, db.Create(provider).Error)
|
||||
|
||||
// Test that non-uptime events are filtered (no actual HTTP call made due to filtering)
|
||||
// This tests the shouldSend logic
|
||||
svc.SendExternal(context.Background(), "domain", "Test", "Test message", nil)
|
||||
|
||||
// If we get here without panic/error, filtering works
|
||||
// (In real test, we'd mock the HTTP client and verify no call was made)
|
||||
}
|
||||
```
|
||||
|
||||
**Coverage Gain:** +6-8 lines
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Cleanup (Estimated +10-14 lines)
|
||||
|
||||
### 4.1 `backend/internal/api/handlers/crowdsec_handler.go` — 82.85% → ~88%
|
||||
|
||||
**Test File:** `backend/internal/api/handlers/crowdsec_handler_test.go` (EXTEND)
|
||||
|
||||
**Uncovered:**
|
||||
|
||||
1. `GetLAPIDecisions`: Non-JSON content-type fallback
|
||||
2. `CheckLAPIHealth`: Fallback to decisions endpoint
|
||||
|
||||
```go
|
||||
// ADD to crowdsec_handler_test.go
|
||||
|
||||
func TestGetLAPIDecisions_NonJSONContentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Mock server that returns HTML instead of JSON
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte("<html>Not JSON</html>"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// This test verifies the content-type check path
|
||||
// The handler should fall back to cscli method
|
||||
// ... test implementation
|
||||
}
|
||||
|
||||
func TestCheckLAPIHealth_FallbackToDecisions(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Mock server where /health fails but /v1/decisions returns 401
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if r.URL.Path == "/health" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1/decisions" {
|
||||
w.WriteHeader(http.StatusUnauthorized) // Expected without auth
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Test verifies the fallback logic
|
||||
// ... test implementation
|
||||
}
|
||||
|
||||
func TestValidateCrowdsecLAPIBaseURL_InvalidURL(t *testing.T) {
|
||||
_, err := validateCrowdsecLAPIBaseURL("invalid://url")
|
||||
require.Error(t, err)
|
||||
}
|
||||
```
|
||||
|
||||
**Coverage Gain:** +5-6 lines
|
||||
|
||||
---
|
||||
|
||||
### 4.2 `backend/internal/api/handlers/dns_provider_handler.go` — 98.30% → 100%
|
||||
|
||||
**Test File:** `backend/internal/api/handlers/dns_provider_handler_test.go` (EXTEND)
|
||||
|
||||
```go
|
||||
// ADD to existing test file
|
||||
|
||||
func TestDNSProviderHandler_InvalidIDParameter(t *testing.T) {
|
||||
// Test with non-numeric ID
|
||||
// ... test implementation
|
||||
}
|
||||
|
||||
func TestDNSProviderHandler_ServiceError(t *testing.T) {
|
||||
// Test handler error response when service returns error
|
||||
// ... test implementation
|
||||
}
|
||||
```
|
||||
|
||||
**Coverage Gain:** +4-5 lines
|
||||
|
||||
---
|
||||
|
||||
### 4.3 `backend/internal/services/uptime_service.go` — 85.71% → 88%
|
||||
|
||||
**Minimal remaining uncovered paths - low priority**
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Frontend: `DNSProviderSelector.tsx` — 86.36% → 100%
|
||||
|
||||
**Test File:** `frontend/src/components/__tests__/DNSProviderSelector.test.tsx` (CREATE)
|
||||
|
||||
```tsx
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import DNSProviderSelector from '../DNSProviderSelector'
|
||||
|
||||
// Mock the hook
|
||||
jest.mock('../../hooks/useDNSProviders', () => ({
|
||||
useDNSProviders: jest.fn(),
|
||||
}))
|
||||
|
||||
const { useDNSProviders } = require('../../hooks/useDNSProviders')
|
||||
|
||||
const wrapper = ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
describe('DNSProviderSelector', () => {
|
||||
it('renders loading state', () => {
|
||||
useDNSProviders.mockReturnValue({ data: [], isLoading: true })
|
||||
|
||||
render(<DNSProviderSelector value={undefined} onChange={() => {}} />, { wrapper })
|
||||
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders empty state when no providers', () => {
|
||||
useDNSProviders.mockReturnValue({ data: [], isLoading: false })
|
||||
|
||||
render(<DNSProviderSelector value={undefined} onChange={() => {}} />, { wrapper })
|
||||
|
||||
// Open the select
|
||||
// Verify empty state message
|
||||
})
|
||||
|
||||
it('displays error message when provided', () => {
|
||||
useDNSProviders.mockReturnValue({ data: [], isLoading: false })
|
||||
|
||||
render(
|
||||
<DNSProviderSelector
|
||||
value={undefined}
|
||||
onChange={() => {}}
|
||||
error="Provider is required"
|
||||
/>,
|
||||
{ wrapper }
|
||||
)
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Provider is required')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Coverage Gain:** +3 lines
|
||||
|
||||
---
|
||||
|
||||
## Summary: Estimated Coverage Impact
|
||||
|
||||
| Phase | Files | Est. Lines Covered | Priority |
|
||||
|-------|-------|-------------------|----------|
|
||||
| Phase 1 | internal_service_client, encryption | +22-24 | IMMEDIATE |
|
||||
| Phase 2 | url_testing, dns_provider_service | +30-38 | HIGH |
|
||||
| Phase 3 | url_validator, notification_service | +14-18 | MEDIUM |
|
||||
| Phase 4 | crowdsec_handler, dns_provider_handler, frontend | +10-14 | LOW |
|
||||
|
||||
**Total Estimated:** +76-94 lines covered
|
||||
|
||||
**Projected Patch Coverage:** 84.85% + ~7-8% = **91-93%**
|
||||
|
||||
---
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
# Run all backend tests with coverage
|
||||
cd backend && go test -coverprofile=coverage.out ./... && go tool cover -func=coverage.out | tail -20
|
||||
|
||||
# Check specific package coverage
|
||||
go test -coverprofile=cover.out ./internal/network/... && go tool cover -func=cover.out
|
||||
|
||||
# Generate HTML report
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
# Frontend coverage
|
||||
cd frontend && npm run test -- --coverage --watchAll=false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] All new test files created
|
||||
- [ ] All test cases implemented
|
||||
- [ ] `go test ./...` passes
|
||||
- [ ] Coverage report shows ≥85% patch coverage
|
||||
- [ ] No linter warnings in new test code
|
||||
- [ ] Pre-commit hooks pass
|
||||
Reference in New Issue
Block a user