Files
Charon/docs/plans/archive/test-coverage-remediation-plan.md
2026-03-04 18:34:49 +00:00

29 KiB

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)

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
// 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
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
// 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: &notDefault,
    })
    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
// 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
// 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
// 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)

// 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)

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

# 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