Some checks are pending
Go Benchmark / Performance Regression Check (push) Waiting to run
Cerberus Integration / Cerberus Security Stack Integration (push) Waiting to run
Upload Coverage to Codecov / Backend Codecov Upload (push) Waiting to run
Upload Coverage to Codecov / Frontend Codecov Upload (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (go) (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (javascript-typescript) (push) Waiting to run
CrowdSec Integration / CrowdSec Bouncer Integration (push) Waiting to run
Docker Build, Publish & Test / build-and-push (push) Waiting to run
Docker Build, Publish & Test / Security Scan PR Image (push) Blocked by required conditions
Quality Checks / Auth Route Protection Contract (push) Waiting to run
Quality Checks / Codecov Trigger/Comment Parity Guard (push) Waiting to run
Quality Checks / Backend (Go) (push) Waiting to run
Quality Checks / Frontend (React) (push) Waiting to run
Rate Limit integration / Rate Limiting Integration (push) Waiting to run
Security Scan (PR) / Trivy Binary Scan (push) Waiting to run
Supply Chain Verification (PR) / Verify Supply Chain (push) Waiting to run
WAF integration / Coraza WAF Integration (push) Waiting to run
932 lines
29 KiB
Go
Executable File
932 lines
29 KiB
Go
Executable File
//go:build integration
|
|
// +build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testConfig holds configuration for LAPI integration tests.
|
|
type testConfig struct {
|
|
BaseURL string
|
|
ContainerName string
|
|
Client *http.Client
|
|
Cookie []*http.Cookie
|
|
}
|
|
|
|
// newTestConfig creates a test configuration with defaults.
|
|
func newTestConfig() *testConfig {
|
|
baseURL := os.Getenv("CHARON_TEST_API_URL")
|
|
if baseURL == "" {
|
|
baseURL = "http://localhost:8080"
|
|
}
|
|
|
|
jar, _ := cookiejar.New(nil)
|
|
client := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Jar: jar,
|
|
}
|
|
|
|
return &testConfig{
|
|
BaseURL: baseURL,
|
|
ContainerName: "charon-e2e",
|
|
Client: client,
|
|
}
|
|
}
|
|
|
|
// authenticate registers and logs in to get session cookies.
|
|
func (tc *testConfig) authenticate(t *testing.T) error {
|
|
t.Helper()
|
|
|
|
// Register (may fail if user exists - that's OK)
|
|
registerPayload := map[string]string{
|
|
"email": "lapi-test@example.local",
|
|
"password": "testpassword123",
|
|
"name": "LAPI Tester",
|
|
}
|
|
payloadBytes, _ := json.Marshal(registerPayload)
|
|
_, _ = tc.Client.Post(tc.BaseURL+"/api/v1/auth/register", "application/json", bytes.NewReader(payloadBytes))
|
|
|
|
// Login
|
|
loginPayload := map[string]string{
|
|
"email": "lapi-test@example.local",
|
|
"password": "testpassword123",
|
|
}
|
|
payloadBytes, _ = json.Marshal(loginPayload)
|
|
resp, err := tc.Client.Post(tc.BaseURL+"/api/v1/auth/login", "application/json", bytes.NewReader(payloadBytes))
|
|
if err != nil {
|
|
return fmt.Errorf("login failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("login returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// doRequest performs an authenticated HTTP request.
|
|
func (tc *testConfig) doRequest(method, path string, body io.Reader) (*http.Response, error) {
|
|
req, err := http.NewRequest(method, tc.BaseURL+path, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
return tc.Client.Do(req)
|
|
}
|
|
|
|
// waitForAPI waits for the API to be ready.
|
|
func (tc *testConfig) waitForAPI(t *testing.T, timeout time.Duration) error {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
resp, err := tc.Client.Get(tc.BaseURL + "/api/v1/")
|
|
if err == nil && resp.StatusCode == http.StatusOK {
|
|
resp.Body.Close()
|
|
return nil
|
|
}
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
return fmt.Errorf("API not ready after %v", timeout)
|
|
}
|
|
|
|
// waitForLAPIReady polls the status endpoint until LAPI is ready or timeout.
|
|
func (tc *testConfig) waitForLAPIReady(t *testing.T, timeout time.Duration) (bool, error) {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
resp, err := tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil)
|
|
if err != nil {
|
|
time.Sleep(1 * time.Second)
|
|
continue
|
|
}
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
var status struct {
|
|
Running bool `json:"running"`
|
|
LapiReady bool `json:"lapi_ready"`
|
|
}
|
|
if err := json.Unmarshal(body, &status); err == nil {
|
|
if status.LapiReady {
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// TestCrowdSecLAPIStartup verifies LAPI can be started via API and becomes ready.
|
|
//
|
|
// Test steps:
|
|
// 1. Start CrowdSec via POST /api/v1/admin/crowdsec/start
|
|
// 2. Wait for LAPI to initialize (up to 30s with polling)
|
|
// 3. Verify: GET /api/v1/admin/crowdsec/status returns lapi_ready: true
|
|
// 4. Use the diagnostic endpoint: GET /api/v1/admin/crowdsec/diagnostics/connectivity
|
|
func TestCrowdSecLAPIStartup(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
tc := newTestConfig()
|
|
|
|
// Wait for API to be ready
|
|
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
|
|
t.Skipf("API not available, skipping test: %v", err)
|
|
}
|
|
|
|
// Authenticate
|
|
if err := tc.authenticate(t); err != nil {
|
|
t.Fatalf("Authentication failed: %v", err)
|
|
}
|
|
|
|
// Step 1: Start CrowdSec
|
|
t.Log("Step 1: Starting CrowdSec via API...")
|
|
resp, err := tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to call start endpoint: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Logf("Start response: %s", string(body))
|
|
|
|
var startResp struct {
|
|
Status string `json:"status"`
|
|
PID int `json:"pid"`
|
|
LapiReady bool `json:"lapi_ready"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(body, &startResp); err != nil {
|
|
t.Logf("Warning: Could not parse start response: %v", err)
|
|
}
|
|
|
|
// Check for expected responses
|
|
if resp.StatusCode != http.StatusOK {
|
|
// CrowdSec binary may not be available
|
|
if strings.Contains(string(body), "not found") || strings.Contains(string(body), "not available") {
|
|
t.Skip("CrowdSec binary not available in container - skipping")
|
|
}
|
|
t.Logf("Start returned non-200 status: %d - continuing to check status", resp.StatusCode)
|
|
}
|
|
|
|
// Step 2: Wait for LAPI to be ready
|
|
t.Log("Step 2: Waiting for LAPI to initialize (up to 30s)...")
|
|
lapiReady, _ := tc.waitForLAPIReady(t, 30*time.Second)
|
|
|
|
// Step 3: Verify status endpoint
|
|
t.Log("Step 3: Verifying status endpoint...")
|
|
resp, err = tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get status: %v", err)
|
|
}
|
|
body, _ = io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Logf("Status response: %s", string(body))
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("Status endpoint returned %d", resp.StatusCode)
|
|
}
|
|
|
|
var statusResp struct {
|
|
Running bool `json:"running"`
|
|
PID int `json:"pid"`
|
|
LapiReady bool `json:"lapi_ready"`
|
|
}
|
|
if err := json.Unmarshal(body, &statusResp); err != nil {
|
|
t.Fatalf("Failed to parse status response: %v", err)
|
|
}
|
|
|
|
t.Logf("CrowdSec status: running=%v, pid=%d, lapi_ready=%v", statusResp.Running, statusResp.PID, statusResp.LapiReady)
|
|
|
|
// Validate: If we managed to start, LAPI should eventually be ready
|
|
// If CrowdSec binary is not available, we expect running=false
|
|
if statusResp.Running && !statusResp.LapiReady && lapiReady {
|
|
t.Error("Expected lapi_ready=true after waiting, but got false")
|
|
}
|
|
|
|
// Step 4: Check diagnostics connectivity endpoint
|
|
t.Log("Step 4: Checking diagnostics connectivity endpoint...")
|
|
resp, err = tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/diagnostics/connectivity", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get diagnostics: %v", err)
|
|
}
|
|
body, _ = io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Logf("Diagnostics connectivity response: %s", string(body))
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("Diagnostics endpoint returned %d", resp.StatusCode)
|
|
}
|
|
|
|
var diagResp map[string]interface{}
|
|
if err := json.Unmarshal(body, &diagResp); err != nil {
|
|
t.Fatalf("Failed to parse diagnostics response: %v", err)
|
|
}
|
|
|
|
// Verify expected fields are present
|
|
expectedFields := []string{"lapi_running", "lapi_ready", "capi_registered", "console_enrolled"}
|
|
for _, field := range expectedFields {
|
|
if _, ok := diagResp[field]; !ok {
|
|
t.Errorf("Expected field '%s' not found in diagnostics response", field)
|
|
}
|
|
}
|
|
|
|
t.Log("TestCrowdSecLAPIStartup completed successfully")
|
|
}
|
|
|
|
// TestCrowdSecLAPIRestartPersistence verifies LAPI can restart and state persists.
|
|
//
|
|
// Test steps:
|
|
// 1. Start CrowdSec
|
|
// 2. Record initial state
|
|
// 3. Stop CrowdSec via API
|
|
// 4. Start CrowdSec again
|
|
// 5. Verify LAPI comes back online
|
|
// 6. Verify state persists
|
|
func TestCrowdSecLAPIRestartPersistence(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
tc := newTestConfig()
|
|
|
|
// Wait for API to be ready
|
|
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
|
|
t.Skipf("API not available, skipping test: %v", err)
|
|
}
|
|
|
|
// Authenticate
|
|
if err := tc.authenticate(t); err != nil {
|
|
t.Fatalf("Authentication failed: %v", err)
|
|
}
|
|
|
|
// Step 1: Start CrowdSec
|
|
t.Log("Step 1: Starting CrowdSec...")
|
|
resp, err := tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to start CrowdSec: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
if strings.Contains(string(body), "not found") || strings.Contains(string(body), "not available") {
|
|
t.Skip("CrowdSec binary not available in container - skipping")
|
|
}
|
|
|
|
// Wait for LAPI to be ready
|
|
lapiReady, _ := tc.waitForLAPIReady(t, 30*time.Second)
|
|
t.Logf("Step 2: Initial LAPI ready state: %v", lapiReady)
|
|
|
|
// Step 3: Stop CrowdSec
|
|
t.Log("Step 3: Stopping CrowdSec...")
|
|
resp, err = tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to stop CrowdSec: %v", err)
|
|
}
|
|
body, _ = io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Logf("Stop response: %s", string(body))
|
|
|
|
// Verify stopped
|
|
time.Sleep(2 * time.Second)
|
|
resp, err = tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get status after stop: %v", err)
|
|
}
|
|
body, _ = io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
var statusResp struct {
|
|
Running bool `json:"running"`
|
|
}
|
|
if err := json.Unmarshal(body, &statusResp); err == nil {
|
|
t.Logf("Status after stop: running=%v", statusResp.Running)
|
|
}
|
|
|
|
// Step 4: Restart CrowdSec
|
|
t.Log("Step 4: Restarting CrowdSec...")
|
|
resp, err = tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to restart CrowdSec: %v", err)
|
|
}
|
|
body, _ = io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Logf("Restart response: %s", string(body))
|
|
|
|
// Step 5: Verify LAPI comes back online
|
|
t.Log("Step 5: Waiting for LAPI to come back online...")
|
|
lapiReadyAfterRestart, _ := tc.waitForLAPIReady(t, 30*time.Second)
|
|
|
|
// Step 6: Verify state
|
|
t.Log("Step 6: Verifying state after restart...")
|
|
resp, err = tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get status after restart: %v", err)
|
|
}
|
|
body, _ = io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Logf("Final status: %s", string(body))
|
|
|
|
var finalStatus struct {
|
|
Running bool `json:"running"`
|
|
LapiReady bool `json:"lapi_ready"`
|
|
}
|
|
if err := json.Unmarshal(body, &finalStatus); err != nil {
|
|
t.Fatalf("Failed to parse final status: %v", err)
|
|
}
|
|
|
|
// If CrowdSec is available, it should be running after restart
|
|
if lapiReady && !lapiReadyAfterRestart {
|
|
t.Error("LAPI was ready before stop but not after restart")
|
|
}
|
|
|
|
t.Log("TestCrowdSecLAPIRestartPersistence completed successfully")
|
|
}
|
|
|
|
// TestCrowdSecDiagnosticsConnectivity verifies the connectivity diagnostics endpoint.
|
|
//
|
|
// Test steps:
|
|
// 1. Start CrowdSec
|
|
// 2. Call GET /api/v1/admin/crowdsec/diagnostics/connectivity
|
|
// 3. Verify response contains all expected fields:
|
|
// - lapi_running
|
|
// - lapi_ready
|
|
// - capi_registered
|
|
// - console_enrolled
|
|
func TestCrowdSecDiagnosticsConnectivity(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
tc := newTestConfig()
|
|
|
|
// Wait for API to be ready
|
|
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
|
|
t.Skipf("API not available, skipping test: %v", err)
|
|
}
|
|
|
|
// Authenticate
|
|
if err := tc.authenticate(t); err != nil {
|
|
t.Fatalf("Authentication failed: %v", err)
|
|
}
|
|
|
|
// Try to start CrowdSec (may fail if binary not available)
|
|
t.Log("Attempting to start CrowdSec...")
|
|
resp, err := tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
|
|
if err == nil {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Logf("Start response: %s", string(body))
|
|
|
|
// Wait briefly for LAPI
|
|
tc.waitForLAPIReady(t, 10*time.Second)
|
|
}
|
|
|
|
// Call diagnostics connectivity endpoint
|
|
t.Log("Calling diagnostics connectivity endpoint...")
|
|
resp, err = tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/diagnostics/connectivity", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get diagnostics connectivity: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Logf("Diagnostics connectivity response: %s", string(body))
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("Diagnostics connectivity returned %d", resp.StatusCode)
|
|
}
|
|
|
|
var diagResp map[string]interface{}
|
|
if err := json.Unmarshal(body, &diagResp); err != nil {
|
|
t.Fatalf("Failed to parse diagnostics response: %v", err)
|
|
}
|
|
|
|
// Verify all required fields are present
|
|
requiredFields := []string{
|
|
"lapi_running",
|
|
"lapi_ready",
|
|
"capi_registered",
|
|
"console_enrolled",
|
|
}
|
|
|
|
for _, field := range requiredFields {
|
|
if _, ok := diagResp[field]; !ok {
|
|
t.Errorf("Required field '%s' not found in diagnostics response", field)
|
|
} else {
|
|
t.Logf("Field '%s': %v", field, diagResp[field])
|
|
}
|
|
}
|
|
|
|
// Optional fields that should be present when applicable
|
|
optionalFields := []string{
|
|
"lapi_pid",
|
|
"capi_reachable",
|
|
"console_reachable",
|
|
"console_status",
|
|
"console_agent_name",
|
|
}
|
|
|
|
for _, field := range optionalFields {
|
|
if val, ok := diagResp[field]; ok {
|
|
t.Logf("Optional field '%s': %v", field, val)
|
|
}
|
|
}
|
|
|
|
t.Log("TestCrowdSecDiagnosticsConnectivity completed successfully")
|
|
}
|
|
|
|
// TestCrowdSecDiagnosticsConfig verifies the config diagnostics endpoint.
|
|
//
|
|
// Test steps:
|
|
// 1. Call GET /api/v1/admin/crowdsec/diagnostics/config
|
|
// 2. Verify response contains:
|
|
// - config_exists
|
|
// - acquis_exists
|
|
// - lapi_port
|
|
// - errors array
|
|
func TestCrowdSecDiagnosticsConfig(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
tc := newTestConfig()
|
|
|
|
// Wait for API to be ready
|
|
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
|
|
t.Skipf("API not available, skipping test: %v", err)
|
|
}
|
|
|
|
// Authenticate
|
|
if err := tc.authenticate(t); err != nil {
|
|
t.Fatalf("Authentication failed: %v", err)
|
|
}
|
|
|
|
// Call diagnostics config endpoint
|
|
t.Log("Calling diagnostics config endpoint...")
|
|
resp, err := tc.doRequest(http.MethodGet, "/api/v1/admin/crowdsec/diagnostics/config", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get diagnostics config: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Logf("Diagnostics config response: %s", string(body))
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("Diagnostics config returned %d", resp.StatusCode)
|
|
}
|
|
|
|
var diagResp map[string]interface{}
|
|
if err := json.Unmarshal(body, &diagResp); err != nil {
|
|
t.Fatalf("Failed to parse diagnostics response: %v", err)
|
|
}
|
|
|
|
// Verify all required fields are present
|
|
requiredFields := []string{
|
|
"config_exists",
|
|
"acquis_exists",
|
|
"lapi_port",
|
|
"errors",
|
|
}
|
|
|
|
for _, field := range requiredFields {
|
|
if _, ok := diagResp[field]; !ok {
|
|
t.Errorf("Required field '%s' not found in diagnostics config response", field)
|
|
} else {
|
|
t.Logf("Field '%s': %v", field, diagResp[field])
|
|
}
|
|
}
|
|
|
|
// Verify errors is an array
|
|
if errors, ok := diagResp["errors"]; ok {
|
|
if _, isArray := errors.([]interface{}); !isArray {
|
|
t.Errorf("Expected 'errors' to be an array, got %T", errors)
|
|
}
|
|
}
|
|
|
|
// Optional fields that may be present when configs exist
|
|
optionalFields := []string{
|
|
"config_valid",
|
|
"acquis_valid",
|
|
"config_path",
|
|
"acquis_path",
|
|
}
|
|
|
|
for _, field := range optionalFields {
|
|
if val, ok := diagResp[field]; ok {
|
|
t.Logf("Optional field '%s': %v", field, val)
|
|
}
|
|
}
|
|
|
|
// Log summary
|
|
t.Logf("Config exists: %v, Acquis exists: %v, LAPI port: %v",
|
|
diagResp["config_exists"],
|
|
diagResp["acquis_exists"],
|
|
diagResp["lapi_port"],
|
|
)
|
|
|
|
t.Log("TestCrowdSecDiagnosticsConfig completed successfully")
|
|
}
|
|
|
|
// Helper: execDockerCommand runs a command inside the container and returns output.
|
|
func execDockerCommand(containerName string, args ...string) (string, error) {
|
|
fullArgs := append([]string{"exec", containerName}, args...)
|
|
cmd := exec.Command("docker", fullArgs...)
|
|
output, err := cmd.CombinedOutput()
|
|
return strings.TrimSpace(string(output)), err
|
|
}
|
|
|
|
// TestBouncerAuth_InvalidEnvKeyAutoRecovers verifies that when an invalid API key is set
|
|
// via environment variable, Charon detects the failure and auto-generates a new valid key.
|
|
//
|
|
// Test Steps:
|
|
// 1. Set CHARON_SECURITY_CROWDSEC_API_KEY=fakeinvalidkey in environment
|
|
// 2. Enable CrowdSec via API
|
|
// 3. Verify logs show:
|
|
// - "Environment variable CHARON_SECURITY_CROWDSEC_API_KEY is set but invalid"
|
|
// - "A new valid key will be generated and saved"
|
|
//
|
|
// 4. Verify new key auto-generated and saved to file
|
|
// 5. Verify Caddy bouncer connects successfully with new key
|
|
func TestBouncerAuth_InvalidEnvKeyAutoRecovers(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
tc := newTestConfig()
|
|
|
|
// Wait for API to be ready
|
|
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
|
|
t.Skipf("API not available, skipping test: %v", err)
|
|
}
|
|
|
|
// Authenticate
|
|
if err := tc.authenticate(t); err != nil {
|
|
t.Fatalf("Authentication failed: %v", err)
|
|
}
|
|
|
|
// Note: Environment variable must be set in docker-compose.yml before starting container.
|
|
// This test assumes CHARON_SECURITY_CROWDSEC_API_KEY=fakeinvalidkey is already set.
|
|
t.Log("Step 1: Assuming invalid environment variable is set (CHARON_SECURITY_CROWDSEC_API_KEY=fakeinvalidkey)")
|
|
|
|
// Step 2: Enable CrowdSec
|
|
t.Log("Step 2: Enabling CrowdSec via API")
|
|
resp, err := tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to start CrowdSec: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && !strings.Contains(string(body), "already running") {
|
|
if strings.Contains(string(body), "not found") || strings.Contains(string(body), "not available") {
|
|
t.Skip("CrowdSec binary not available - skipping")
|
|
}
|
|
t.Logf("Start response: %s (continuing despite non-200 status)", string(body))
|
|
}
|
|
|
|
// Wait for LAPI to initialize
|
|
tc.waitForLAPIReady(t, 30*time.Second)
|
|
|
|
// Step 3: Check logs for auto-recovery messages
|
|
t.Log("Step 3: Checking container logs for auto-recovery messages")
|
|
logs, err := execDockerCommand(tc.ContainerName, "cat", "/var/log/charon/charon.log")
|
|
if err != nil {
|
|
// Try docker logs command if log file doesn't exist
|
|
cmd := exec.Command("docker", "logs", "--tail", "200", tc.ContainerName)
|
|
output, _ := cmd.CombinedOutput()
|
|
logs = string(output)
|
|
}
|
|
|
|
if !strings.Contains(logs, "Environment variable") && !strings.Contains(logs, "invalid") {
|
|
t.Logf("Warning: Expected warning messages not found in logs. This may indicate env var was not set before container start.")
|
|
t.Logf("Logs (last 500 chars): %s", logs[max(0, len(logs)-500):])
|
|
}
|
|
|
|
// Step 4: Verify key file exists and contains a valid key
|
|
t.Log("Step 4: Verifying bouncer key file exists")
|
|
keyFilePath := "/app/data/crowdsec/bouncer_key"
|
|
generatedKey, err := execDockerCommand(tc.ContainerName, "cat", keyFilePath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read bouncer key file: %v", err)
|
|
}
|
|
|
|
if generatedKey == "" {
|
|
t.Fatal("Bouncer key file is empty")
|
|
}
|
|
|
|
if generatedKey == "fakeinvalidkey" {
|
|
t.Fatal("Key should be regenerated, not the invalid env var")
|
|
}
|
|
|
|
t.Logf("Generated key (masked): %s...%s", generatedKey[:min(4, len(generatedKey))], generatedKey[max(0, len(generatedKey)-4):])
|
|
|
|
// Step 5: Verify Caddy bouncer can authenticate with generated key
|
|
t.Log("Step 5: Verifying Caddy bouncer authentication with generated key")
|
|
lapiURL := tc.BaseURL // LAPI is on same host in test environment
|
|
req, err := http.NewRequest("GET", lapiURL+"/v1/decisions/stream", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create LAPI request: %v", err)
|
|
}
|
|
req.Header.Set("X-Api-Key", generatedKey)
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
decisionsResp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to query LAPI: %v", err)
|
|
}
|
|
defer decisionsResp.Body.Close()
|
|
|
|
if decisionsResp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(decisionsResp.Body)
|
|
t.Fatalf("LAPI authentication failed with status %d: %s", decisionsResp.StatusCode, string(respBody))
|
|
}
|
|
|
|
t.Log("✅ Auto-recovery from invalid env var successful")
|
|
}
|
|
|
|
// TestBouncerAuth_ValidEnvKeyPreserved verifies that when a valid API key is set
|
|
// via environment variable, it is used without triggering new registration.
|
|
//
|
|
// Test Steps:
|
|
// 1. Pre-register bouncer with cscli
|
|
// 2. Note: Registered key must be set as CHARON_SECURITY_CROWDSEC_API_KEY before starting container
|
|
// 3. Enable CrowdSec
|
|
// 4. Verify logs show "source=environment_variable"
|
|
// 5. Verify no duplicate bouncer registration
|
|
// 6. Verify authentication works with env key
|
|
func TestBouncerAuth_ValidEnvKeyPreserved(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
tc := newTestConfig()
|
|
|
|
// Wait for API to be ready
|
|
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
|
|
t.Skipf("API not available, skipping test: %v", err)
|
|
}
|
|
|
|
// Authenticate
|
|
if err := tc.authenticate(t); err != nil {
|
|
t.Fatalf("Authentication failed: %v", err)
|
|
}
|
|
|
|
// Step 1: Pre-register bouncer (if not already registered)
|
|
t.Log("Step 1: Checking if bouncer is pre-registered")
|
|
listOutput, err := execDockerCommand(tc.ContainerName, "cscli", "bouncers", "list", "-o", "json")
|
|
if err != nil {
|
|
t.Logf("Failed to list bouncers: %v (this is expected if CrowdSec not fully initialized)", err)
|
|
}
|
|
|
|
bouncerExists := strings.Contains(listOutput, `"name":"caddy-bouncer"`)
|
|
t.Logf("Bouncer exists: %v", bouncerExists)
|
|
|
|
// Step 2: Note - Environment variable must be set in docker-compose.yml with the registered key
|
|
t.Log("Step 2: Assuming valid environment variable is set (must match pre-registered key)")
|
|
|
|
// Step 3: Enable CrowdSec
|
|
t.Log("Step 3: Enabling CrowdSec via API")
|
|
resp, err := tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to start CrowdSec: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && !strings.Contains(string(body), "already running") {
|
|
if strings.Contains(string(body), "not found") || strings.Contains(string(body), "not available") {
|
|
t.Skip("CrowdSec binary not available - skipping")
|
|
}
|
|
t.Logf("Start response: %s (continuing)", string(body))
|
|
}
|
|
|
|
// Wait for LAPI
|
|
tc.waitForLAPIReady(t, 30*time.Second)
|
|
|
|
// Step 4: Check logs for environment variable source
|
|
t.Log("Step 4: Checking logs for env var source indicator")
|
|
logs, err := execDockerCommand(tc.ContainerName, "cat", "/var/log/charon/charon.log")
|
|
if err != nil {
|
|
cmd := exec.Command("docker", "logs", "--tail", "200", tc.ContainerName)
|
|
output, _ := cmd.CombinedOutput()
|
|
logs = string(output)
|
|
}
|
|
|
|
if !strings.Contains(logs, "source=environment_variable") {
|
|
t.Logf("Warning: Expected 'source=environment_variable' not found in logs")
|
|
t.Logf("This may indicate the env var was not set before container start")
|
|
}
|
|
|
|
// Step 5: Verify no duplicate bouncer registration
|
|
t.Log("Step 5: Verifying no duplicate bouncer registration")
|
|
listOutputAfter, err := execDockerCommand(tc.ContainerName, "cscli", "bouncers", "list", "-o", "json")
|
|
if err == nil {
|
|
bouncerCount := strings.Count(listOutputAfter, `"name":"caddy-bouncer"`)
|
|
if bouncerCount > 1 {
|
|
t.Errorf("Expected exactly 1 bouncer, found %d duplicates", bouncerCount)
|
|
}
|
|
t.Logf("Bouncer count: %d (expected 1)", bouncerCount)
|
|
}
|
|
|
|
// Step 6: Verify authentication works
|
|
t.Log("Step 6: Verifying authentication (key must be set correctly in env)")
|
|
keyFromFile, err := execDockerCommand(tc.ContainerName, "cat", "/app/data/crowdsec/bouncer_key")
|
|
if err != nil {
|
|
t.Logf("Could not read key file: %v", err)
|
|
return // Cannot verify without key
|
|
}
|
|
|
|
lapiURL := tc.BaseURL
|
|
req, err := http.NewRequest("GET", lapiURL+"/v1/decisions/stream", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create LAPI request: %v", err)
|
|
}
|
|
req.Header.Set("X-Api-Key", strings.TrimSpace(keyFromFile))
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
decisionsResp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to query LAPI: %v", err)
|
|
}
|
|
defer decisionsResp.Body.Close()
|
|
|
|
if decisionsResp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(decisionsResp.Body)
|
|
t.Errorf("LAPI authentication failed with status %d: %s", decisionsResp.StatusCode, string(respBody))
|
|
} else {
|
|
t.Log("✅ Valid environment variable preserved successfully")
|
|
}
|
|
}
|
|
|
|
// TestBouncerAuth_FileKeyPersistsAcrossRestarts verifies that an auto-generated key
|
|
// is saved to file and reused across container restarts.
|
|
//
|
|
// Test Steps:
|
|
// 1. Clear any existing key file
|
|
// 2. Enable CrowdSec (triggers auto-generation)
|
|
// 3. Read generated key from file
|
|
// 4. Restart Charon container
|
|
// 5. Verify same key is still in file
|
|
// 6. Verify logs show "source=file"
|
|
// 7. Verify authentication works with persisted key
|
|
func TestBouncerAuth_FileKeyPersistsAcrossRestarts(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
tc := newTestConfig()
|
|
|
|
// Wait for API to be ready
|
|
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
|
|
t.Skipf("API not available, skipping test: %v", err)
|
|
}
|
|
|
|
// Authenticate
|
|
if err := tc.authenticate(t); err != nil {
|
|
t.Fatalf("Authentication failed: %v", err)
|
|
}
|
|
|
|
// Step 1: Clear key file (note: requires container to be started without env var set)
|
|
t.Log("Step 1: Clearing key file")
|
|
keyFilePath := "/app/data/crowdsec/bouncer_key"
|
|
_, _ = execDockerCommand(tc.ContainerName, "rm", "-f", keyFilePath) // Ignore error if file doesn't exist
|
|
|
|
// Step 2: Enable CrowdSec to trigger key auto-generation
|
|
t.Log("Step 2: Enabling CrowdSec to trigger key auto-generation")
|
|
resp, err := tc.doRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to start CrowdSec: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && !strings.Contains(string(body), "already running") {
|
|
if strings.Contains(string(body), "not found") || strings.Contains(string(body), "not available") {
|
|
t.Skip("CrowdSec binary not available - skipping")
|
|
}
|
|
}
|
|
|
|
// Wait for LAPI and key generation
|
|
tc.waitForLAPIReady(t, 30*time.Second)
|
|
time.Sleep(5 * time.Second) // Allow time for key file creation
|
|
|
|
// Step 3: Read generated key
|
|
t.Log("Step 3: Reading generated key from file")
|
|
originalKey, err := execDockerCommand(tc.ContainerName, "cat", keyFilePath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read bouncer key file after generation: %v", err)
|
|
}
|
|
|
|
if originalKey == "" {
|
|
t.Fatal("Bouncer key file is empty after generation")
|
|
}
|
|
|
|
t.Logf("Original key (masked): %s...%s", originalKey[:min(4, len(originalKey))], originalKey[max(0, len(originalKey)-4):])
|
|
|
|
// Step 4: Restart container
|
|
t.Log("Step 4: Restarting Charon container")
|
|
cmd := exec.Command("docker", "restart", tc.ContainerName)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("Failed to restart container: %v, output: %s", err, string(output))
|
|
}
|
|
|
|
// Wait for container to come back up
|
|
time.Sleep(10 * time.Second)
|
|
if err := tc.waitForAPI(t, 60*time.Second); err != nil {
|
|
t.Fatalf("API not available after restart: %v", err)
|
|
}
|
|
|
|
// Re-authenticate after restart
|
|
if err := tc.authenticate(t); err != nil {
|
|
t.Fatalf("Authentication failed after restart: %v", err)
|
|
}
|
|
|
|
// Step 5: Verify same key persisted
|
|
t.Log("Step 5: Verifying key persisted after restart")
|
|
persistedKey, err := execDockerCommand(tc.ContainerName, "cat", keyFilePath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read bouncer key file after restart: %v", err)
|
|
}
|
|
|
|
if persistedKey != originalKey {
|
|
t.Errorf("Key changed after restart. Original: %s...%s, After: %s...%s",
|
|
originalKey[:4], originalKey[len(originalKey)-4:],
|
|
persistedKey[:min(4, len(persistedKey))], persistedKey[max(0, len(persistedKey)-4):])
|
|
}
|
|
|
|
// Step 6: Verify logs show file source
|
|
t.Log("Step 6: Checking logs for file source indicator")
|
|
logs, err := execDockerCommand(tc.ContainerName, "cat", "/var/log/charon/charon.log")
|
|
if err != nil {
|
|
cmd := exec.Command("docker", "logs", "--tail", "200", tc.ContainerName)
|
|
output, _ := cmd.CombinedOutput()
|
|
logs = string(output)
|
|
}
|
|
|
|
if !strings.Contains(logs, "source=file") {
|
|
t.Logf("Warning: Expected 'source=file' not found in logs after restart")
|
|
}
|
|
|
|
// Step 7: Verify authentication with persisted key
|
|
t.Log("Step 7: Verifying authentication with persisted key")
|
|
lapiURL := tc.BaseURL
|
|
req, err := http.NewRequest("GET", lapiURL+"/v1/decisions/stream", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create LAPI request: %v", err)
|
|
}
|
|
req.Header.Set("X-Api-Key", persistedKey)
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
decisionsResp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to query LAPI: %v", err)
|
|
}
|
|
defer decisionsResp.Body.Close()
|
|
|
|
if decisionsResp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(decisionsResp.Body)
|
|
t.Fatalf("LAPI authentication failed with status %d: %s", decisionsResp.StatusCode, string(respBody))
|
|
}
|
|
|
|
t.Log("✅ File key persistence across restarts successful")
|
|
}
|
|
|
|
// Helper: min returns the minimum of two integers
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Helper: max returns the maximum of two integers
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|