caddy/config_test.go
Mohammed Al Sahaf fc63a3c3f5
config tests
Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com>
2025-08-30 21:56:28 +03:00

719 lines
17 KiB
Go

// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package caddy
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestConfig_Start_Stop_Basic(t *testing.T) {
cfg := &Config{
Admin: &AdminConfig{Disabled: true}, // Disable admin to avoid port conflicts
}
ctx, err := run(cfg, true)
if err != nil {
t.Fatalf("Failed to run config: %v", err)
}
// Verify context is valid
if ctx.cfg == nil {
t.Error("Expected non-nil config in context")
}
// Stop the config
unsyncedStop(ctx)
// Verify cleanup was called
if ctx.cfg.cancelFunc == nil {
t.Error("Expected cancel function to be set")
}
}
func TestConfig_Validate_InvalidConfig(t *testing.T) {
// Create a config with an invalid app module
cfg := &Config{
AppsRaw: ModuleMap{
"non-existent-app": json.RawMessage(`{}`),
},
}
err := Validate(cfg)
if err == nil {
t.Error("Expected validation error for invalid app module")
}
}
func TestConfig_Validate_ValidConfig(t *testing.T) {
cfg := &Config{
Admin: &AdminConfig{Disabled: true},
}
err := Validate(cfg)
if err != nil {
t.Errorf("Unexpected validation error: %v", err)
}
}
func TestChangeConfig_ConcurrentAccess(t *testing.T) {
// Save original config state
originalRawCfg := rawCfg[rawConfigKey]
originalRawCfgJSON := rawCfgJSON
defer func() {
rawCfg[rawConfigKey] = originalRawCfg
rawCfgJSON = originalRawCfgJSON
}()
// Initialize with a basic config
initialCfg := map[string]any{
"test": "value",
}
rawCfg[rawConfigKey] = initialCfg
const numGoroutines = 10 // Reduced for more controlled testing
var wg sync.WaitGroup
errors := make([]error, numGoroutines)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
// Only test read operations to avoid complex state changes
// that could cause nil pointer issues in concurrent scenarios
var buf bytes.Buffer
errors[index] = readConfig("/"+rawConfigKey+"/test", &buf)
}(i)
}
wg.Wait()
// Check that read operations succeeded
for i, err := range errors {
if err != nil {
t.Errorf("Goroutine %d: Unexpected read error: %v", i, err)
}
}
}
func TestChangeConfig_MethodValidation(t *testing.T) {
// Save original config state
originalRawCfg := rawCfg[rawConfigKey]
defer func() {
rawCfg[rawConfigKey] = originalRawCfg
}()
// Set up a simple valid config for testing
rawCfg[rawConfigKey] = map[string]any{}
tests := []struct {
method string
expectErr bool
}{
{http.MethodPost, false},
{http.MethodPut, true}, // because key 'admin' already exists
{http.MethodPatch, false},
{http.MethodDelete, false},
{http.MethodGet, true},
{http.MethodHead, true},
{http.MethodOptions, true},
{http.MethodConnect, true},
{http.MethodTrace, true},
}
for _, test := range tests {
t.Run(test.method, func(t *testing.T) {
// Use a simple admin config path that won't cause complex validation
err := changeConfig(test.method, "/"+rawConfigKey+"/admin", []byte(`{"disabled": true}`), "", false)
if test.expectErr && err == nil {
t.Error("Expected error for invalid method")
}
if !test.expectErr && err != nil && (err != errSameConfig) {
t.Errorf("Unexpected error: %v", err)
}
})
}
}
func TestChangeConfig_IfMatchHeader_Validation(t *testing.T) {
// Set up initial config
initialCfg := map[string]any{"test": "value"}
rawCfg[rawConfigKey] = initialCfg
tests := []struct {
name string
ifMatch string
expectErr bool
expectStatusCode int
}{
{
name: "malformed - no quotes",
ifMatch: "path hash",
expectErr: true,
expectStatusCode: http.StatusBadRequest,
},
{
name: "malformed - single quote",
ifMatch: `"path hash`,
expectErr: true,
expectStatusCode: http.StatusBadRequest,
},
{
name: "malformed - wrong number of parts",
ifMatch: `"path"`,
expectErr: true,
expectStatusCode: http.StatusBadRequest,
},
{
name: "malformed - too many parts",
ifMatch: `"path hash extra"`,
expectErr: true,
expectStatusCode: http.StatusBadRequest,
},
{
name: "wrong hash",
ifMatch: `"/config/test wronghash"`,
expectErr: true,
expectStatusCode: http.StatusPreconditionFailed,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := changeConfig(http.MethodPost, "/"+rawConfigKey+"/test", []byte(`"newvalue"`), test.ifMatch, false)
if test.expectErr && err == nil {
t.Error("Expected error")
}
if !test.expectErr && err != nil {
t.Errorf("Unexpected error: %v", err)
}
if test.expectErr && err != nil {
if apiErr, ok := err.(APIError); ok {
if apiErr.HTTPStatus != test.expectStatusCode {
t.Errorf("Expected status %d, got %d", test.expectStatusCode, apiErr.HTTPStatus)
}
} else {
t.Error("Expected APIError type")
}
}
})
}
}
func TestIndexConfigObjects_Basic(t *testing.T) {
config := map[string]any{
"app1": map[string]any{
"@id": "my-app",
"config": "value",
},
"nested": map[string]any{
"array": []any{
map[string]any{
"@id": "nested-item",
"data": "test",
},
map[string]any{
"@id": 123.0, // JSON numbers are float64
"more": "data",
},
},
},
}
index := make(map[string]string)
err := indexConfigObjects(config, "/config", index)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
expected := map[string]string{
"my-app": "/config/app1",
"nested-item": "/config/nested/array/0",
"123": "/config/nested/array/1",
}
if len(index) != len(expected) {
t.Errorf("Expected %d indexed items, got %d", len(expected), len(index))
}
for id, expectedPath := range expected {
if actualPath, exists := index[id]; !exists || actualPath != expectedPath {
t.Errorf("ID %s: expected path '%s', got '%s'", id, expectedPath, actualPath)
}
}
}
func TestIndexConfigObjects_InvalidID(t *testing.T) {
config := map[string]any{
"app": map[string]any{
"@id": map[string]any{"invalid": "id"}, // Invalid ID type
},
}
index := make(map[string]string)
err := indexConfigObjects(config, "/config", index)
if err == nil {
t.Error("Expected error for invalid ID type")
}
}
func TestRun_AppStartFailure(t *testing.T) {
// Register a mock app that fails to start
RegisterModule(&failingApp{})
defer func() {
// Clean up module registry
delete(modules, "failing-app")
}()
cfg := &Config{
Admin: &AdminConfig{Disabled: true},
AppsRaw: ModuleMap{
"failing-app": json.RawMessage(`{}`),
},
}
_, err := run(cfg, true)
if err == nil {
t.Error("Expected error when app fails to start")
}
// Should contain the app name in the error
if err.Error() == "" {
t.Error("Expected descriptive error message")
}
}
func TestRun_AppStopFailure_During_Cleanup(t *testing.T) {
// Register apps where one fails to start and another fails to stop
RegisterModule(&workingApp{})
RegisterModule(&failingStopApp{})
defer func() {
delete(modules, "working-app")
delete(modules, "failing-stop-app")
}()
cfg := &Config{
Admin: &AdminConfig{Disabled: true},
AppsRaw: ModuleMap{
"working-app": json.RawMessage(`{}`),
"failing-stop-app": json.RawMessage(`{}`),
},
}
// Start both apps
ctx, err := run(cfg, true)
if err != nil {
t.Fatalf("Unexpected error starting apps: %v", err)
}
// Stop context - this should handle stop failures gracefully
unsyncedStop(ctx)
// Test passed if we reach here without panic
}
func TestProvisionContext_NilConfig(t *testing.T) {
ctx, err := provisionContext(nil, false)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if ctx.cfg == nil {
t.Error("Expected non-nil config even when input is nil")
}
// Clean up
ctx.cfg.cancelFunc()
}
func TestDuration_UnmarshalJSON_EdgeCases(t *testing.T) {
tests := []struct {
name string
input string
expectErr bool
expected time.Duration
}{
{
name: "empty input",
input: "",
expectErr: true,
},
{
name: "integer nanoseconds",
input: "1000000000",
expected: time.Second,
expectErr: false,
},
{
name: "string duration",
input: `"5m30s"`,
expected: 5*time.Minute + 30*time.Second,
expectErr: false,
},
{
name: "days conversion",
input: `"2d"`,
expected: 48 * time.Hour,
expectErr: false,
},
{
name: "mixed days and hours",
input: `"1d12h"`,
expected: 36 * time.Hour,
expectErr: false,
},
{
name: "invalid duration",
input: `"invalid"`,
expectErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var d Duration
err := d.UnmarshalJSON([]byte(test.input))
if test.expectErr && err == nil {
t.Error("Expected error")
}
if !test.expectErr && err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !test.expectErr && time.Duration(d) != test.expected {
t.Errorf("Expected %v, got %v", test.expected, time.Duration(d))
}
})
}
}
func TestParseDuration_LongInput(t *testing.T) {
// Test input length limit
longInput := string(make([]byte, 1025)) // Exceeds 1024 limit
for i := range longInput {
longInput = longInput[:i] + "1"
}
longInput += "d"
_, err := ParseDuration(longInput)
if err == nil {
t.Error("Expected error for input longer than 1024 characters")
}
}
func TestVersion_Deterministic(t *testing.T) {
// Test that Version() returns consistent results
simple1, full1 := Version()
simple2, full2 := Version()
if simple1 != simple2 {
t.Errorf("Version() simple form not deterministic: '%s' != '%s'", simple1, simple2)
}
if full1 != full2 {
t.Errorf("Version() full form not deterministic: '%s' != '%s'", full1, full2)
}
}
func TestInstanceID_Consistency(t *testing.T) {
// Test that InstanceID returns the same ID on subsequent calls
id1, err := InstanceID()
if err != nil {
t.Fatalf("Failed to get instance ID: %v", err)
}
id2, err := InstanceID()
if err != nil {
t.Fatalf("Failed to get instance ID on second call: %v", err)
}
if id1 != id2 {
t.Errorf("InstanceID not consistent: %v != %v", id1, id2)
}
}
func TestRemoveMetaFields_EdgeCases(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "no meta fields",
input: `{"normal": "field"}`,
expected: `{"normal": "field"}`,
},
{
name: "single @id field",
input: `{"@id": "test", "other": "field"}`,
expected: `{"other": "field"}`,
},
{
name: "@id at beginning",
input: `{"@id": "test", "other": "field"}`,
expected: `{"other": "field"}`,
},
{
name: "@id at end",
input: `{"other": "field", "@id": "test"}`,
expected: `{"other": "field"}`,
},
{
name: "@id in middle",
input: `{"first": "value", "@id": "test", "last": "value"}`,
expected: `{"first": "value", "last": "value"}`,
},
{
name: "multiple @id fields",
input: `{"@id": "test1", "other": "field", "@id": "test2"}`,
expected: `{"other": "field"}`,
},
{
name: "numeric @id",
input: `{"@id": 123, "other": "field"}`,
expected: `{"other": "field"}`,
},
{
name: "nested objects with @id",
input: `{"outer": {"@id": "nested", "data": "value"}}`,
expected: `{"outer": {"data": "value"}}`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := RemoveMetaFields([]byte(test.input))
// resultStr := string(result)
// Parse both to ensure valid JSON and compare structures
var expectedObj, resultObj any
if err := json.Unmarshal([]byte(test.expected), &expectedObj); err != nil {
t.Fatalf("Expected result is not valid JSON: %v", err)
}
if err := json.Unmarshal(result, &resultObj); err != nil {
t.Fatalf("Result is not valid JSON: %v", err)
}
// Note: We can't do exact string comparison due to potential field ordering
// Instead, verify the structure matches
expectedJSON, _ := json.Marshal(expectedObj)
resultJSON, _ := json.Marshal(resultObj)
if string(expectedJSON) != string(resultJSON) {
t.Errorf("Expected %s, got %s", string(expectedJSON), string(resultJSON))
}
})
}
}
func TestUnsyncedConfigAccess_ArrayOperations_EdgeCases(t *testing.T) {
// Test array boundary conditions and edge cases
tests := []struct {
name string
initialState map[string]any
method string
path string
payload string
expectErr bool
expectState map[string]any
}{
{
name: "delete from empty array",
initialState: map[string]any{"arr": []any{}},
method: http.MethodDelete,
path: "/config/arr/0",
expectErr: true,
},
{
name: "access negative index",
initialState: map[string]any{"arr": []any{"a", "b"}},
method: http.MethodGet,
path: "/config/arr/-1",
expectErr: true,
},
{
name: "put at index beyond end",
initialState: map[string]any{"arr": []any{"a"}},
method: http.MethodPut,
path: "/config/arr/5",
payload: `"new"`,
expectErr: true,
},
{
name: "patch non-existent index",
initialState: map[string]any{"arr": []any{"a"}},
method: http.MethodPatch,
path: "/config/arr/5",
payload: `"new"`,
expectErr: true,
},
{
name: "put at exact end of array",
initialState: map[string]any{"arr": []any{"a", "b"}},
method: http.MethodPut,
path: "/config/arr/2",
payload: `"c"`,
expectState: map[string]any{"arr": []any{"a", "b", "c"}},
},
{
name: "ellipses with non-array payload",
initialState: map[string]any{"arr": []any{"a"}},
method: http.MethodPost,
path: "/config/arr/...",
payload: `"not-array"`,
expectErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Set up initial state
rawCfg[rawConfigKey] = test.initialState
err := unsyncedConfigAccess(test.method, test.path, []byte(test.payload), nil)
if test.expectErr && err == nil {
t.Error("Expected error")
}
if !test.expectErr && err != nil {
t.Errorf("Unexpected error: %v", err)
}
if test.expectState != nil {
// Compare resulting state
expectedJSON, _ := json.Marshal(test.expectState)
actualJSON, _ := json.Marshal(rawCfg[rawConfigKey])
if string(expectedJSON) != string(actualJSON) {
t.Errorf("Expected state %s, got %s", string(expectedJSON), string(actualJSON))
}
}
})
}
}
func TestExitProcess_ConcurrentCalls(t *testing.T) {
// Test that multiple concurrent calls to exitProcess are safe
// We can't test the actual exit, but we can test the atomic flag
// Reset the exiting flag
oldExiting := exiting
exiting = new(int32)
defer func() { exiting = oldExiting }()
const numGoroutines = 10
var wg sync.WaitGroup
results := make([]bool, numGoroutines)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
// Check the Exiting() function which reads the atomic flag
wasExitingBefore := Exiting()
// This would call exitProcess, but we don't want to actually exit
// So we just test the atomic operation directly
results[index] = atomic.CompareAndSwapInt32(exiting, 0, 1)
wasExitingAfter := Exiting()
// At least one should succeed in setting the flag
if !wasExitingBefore && wasExitingAfter && !results[index] {
t.Errorf("Goroutine %d: Flag was set but CAS failed", index)
}
}(i)
}
wg.Wait()
// Exactly one goroutine should have successfully set the flag
successCount := 0
for _, success := range results {
if success {
successCount++
}
}
if successCount != 1 {
t.Errorf("Expected exactly 1 successful flag set, got %d", successCount)
}
// Flag should be set
if !Exiting() {
t.Error("Exiting flag should be set")
}
}
// Mock apps for testing
type failingApp struct{}
func (fa *failingApp) CaddyModule() ModuleInfo {
return ModuleInfo{
ID: "failing-app",
New: func() Module { return new(failingApp) },
}
}
func (fa *failingApp) Start() error {
return fmt.Errorf("simulated start failure")
}
func (fa *failingApp) Stop() error {
return nil
}
type workingApp struct{}
func (wa *workingApp) CaddyModule() ModuleInfo {
return ModuleInfo{
ID: "working-app",
New: func() Module { return new(workingApp) },
}
}
func (wa *workingApp) Start() error {
return nil
}
func (wa *workingApp) Stop() error {
return nil
}
type failingStopApp struct{}
func (fsa *failingStopApp) CaddyModule() ModuleInfo {
return ModuleInfo{
ID: "failing-stop-app",
New: func() Module { return new(failingStopApp) },
}
}
func (fsa *failingStopApp) Start() error {
return nil
}
func (fsa *failingStopApp) Stop() error {
return fmt.Errorf("simulated stop failure")
}