From 4920afdafb49d68b84e5598ce09b07e39f376824 Mon Sep 17 00:00:00 2001 From: Stefan Prodan Date: Wed, 20 May 2026 11:07:35 +0300 Subject: [PATCH] Improve test coverage of the HTTP API Signed-off-by: Stefan Prodan --- pkg/api/http/cache_test.go | 50 ++++++++++++++++ pkg/api/http/configs_test.go | 22 +++++++ pkg/api/http/delay_test.go | 63 ++++++++++++++++++++ pkg/api/http/echo_test.go | 109 +++++++++++++++++++++++++++++++++++ pkg/api/http/echows_test.go | 36 ++++++++++++ pkg/api/http/env_test.go | 14 +++++ pkg/api/http/health_test.go | 66 +++++++++++++++++++++ pkg/api/http/http_test.go | 23 ++++++++ pkg/api/http/status_test.go | 25 ++++++++ pkg/api/http/store_test.go | 48 +++++++++++++++ pkg/api/http/token_test.go | 94 ++++++++++++++++++++++++++++++ 11 files changed, 550 insertions(+) create mode 100644 pkg/api/http/cache_test.go create mode 100644 pkg/api/http/configs_test.go create mode 100644 pkg/api/http/echows_test.go create mode 100644 pkg/api/http/http_test.go diff --git a/pkg/api/http/cache_test.go b/pkg/api/http/cache_test.go new file mode 100644 index 0000000..b5c3a28 --- /dev/null +++ b/pkg/api/http/cache_test.go @@ -0,0 +1,50 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCacheWriteHandler_NoPool(t *testing.T) { + srv := NewMockServer() + srv.pool = nil + srv.router.HandleFunc("/cache/{key}", srv.cacheWriteHandler) + + req, _ := http.NewRequest("POST", "/cache/key1", strings.NewReader("value1")) + rr := httptest.NewRecorder() + srv.router.ServeHTTP(rr, req) + + if !strings.Contains(rr.Body.String(), "cache server is offline") { + t.Errorf("expected offline error, got: %s", rr.Body.String()) + } +} + +func TestCacheDeleteHandler_NoPool(t *testing.T) { + srv := NewMockServer() + srv.pool = nil + srv.router.HandleFunc("/cache/{key}", srv.cacheDeleteHandler).Methods("DELETE") + + req, _ := http.NewRequest("DELETE", "/cache/key1", nil) + rr := httptest.NewRecorder() + srv.router.ServeHTTP(rr, req) + + if !strings.Contains(rr.Body.String(), "cache server is offline") { + t.Errorf("expected offline error, got: %s", rr.Body.String()) + } +} + +func TestCacheReadHandler_NoPool(t *testing.T) { + srv := NewMockServer() + srv.pool = nil + srv.router.HandleFunc("/cache/{key}", srv.cacheReadHandler).Methods("GET") + + req, _ := http.NewRequest("GET", "/cache/key1", nil) + rr := httptest.NewRecorder() + srv.router.ServeHTTP(rr, req) + + if !strings.Contains(rr.Body.String(), "cache server is offline") { + t.Errorf("expected offline error, got: %s", rr.Body.String()) + } +} diff --git a/pkg/api/http/configs_test.go b/pkg/api/http/configs_test.go new file mode 100644 index 0000000..6940648 --- /dev/null +++ b/pkg/api/http/configs_test.go @@ -0,0 +1,22 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestConfigReadHandler(t *testing.T) { + srv := NewMockServer() + req, _ := http.NewRequest("GET", "/configs", nil) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.configReadHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusOK) + } + if !strings.Contains(rr.Body.String(), "{}") { + t.Errorf("expected empty JSON object, got: %s", rr.Body.String()) + } +} diff --git a/pkg/api/http/delay_test.go b/pkg/api/http/delay_test.go index 9309692..2a4de79 100644 --- a/pkg/api/http/delay_test.go +++ b/pkg/api/http/delay_test.go @@ -33,3 +33,66 @@ func TestDelayHandler(t *testing.T) { rr.Body.String(), expected) } } + +func TestRandomDelayMiddleware(t *testing.T) { + m := NewRandomDelayMiddleware(0, 1, "ms") + called := false + handler := m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if !called { + t.Error("next handler was not called") + } + if rr.Code != http.StatusOK { + t.Errorf("got status %d, want %d", rr.Code, http.StatusOK) + } +} + +func TestRandomDelayMiddleware_Milliseconds(t *testing.T) { + m := NewRandomDelayMiddleware(0, 1, "ms") + handler := m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("got status %d, want %d", rr.Code, http.StatusOK) + } +} + +func TestRandomErrorMiddleware(t *testing.T) { + handler := randomErrorMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + gotOK := false + gotError := false + for i := 0; i < 50; i++ { + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code == http.StatusOK { + gotOK = true + } else { + gotError = true + } + if gotOK && gotError { + break + } + } + if !gotOK { + t.Error("never got a successful response") + } + if !gotError { + t.Error("never got an error response") + } +} diff --git a/pkg/api/http/echo_test.go b/pkg/api/http/echo_test.go index 7fdbd29..b09a796 100644 --- a/pkg/api/http/echo_test.go +++ b/pkg/api/http/echo_test.go @@ -74,3 +74,112 @@ func TestEchoHandler_ContentType(t *testing.T) { } } } + +func TestCopyTracingHeaders(t *testing.T) { + from, _ := http.NewRequest("GET", "/", nil) + from.Header.Set("x-request-id", "abc123") + from.Header.Set("x-b3-traceid", "trace-1") + from.Header.Set("x-b3-spanid", "span-1") + + to, _ := http.NewRequest("POST", "/echo", nil) + copyTracingHeaders(from, to) + + if to.Header.Get("x-request-id") != "abc123" { + t.Errorf("x-request-id not copied") + } + if to.Header.Get("x-b3-traceid") != "trace-1" { + t.Errorf("x-b3-traceid not copied") + } + if to.Header.Get("x-b3-spanid") != "span-1" { + t.Errorf("x-b3-spanid not copied") + } +} + +func TestEchoHandler_WithBackend(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`"backend response"`)) + })) + defer backend.Close() + + srv := NewMockServer() + srv.config.BackendURL = []string{backend.URL} + + req, _ := http.NewRequest("POST", "/echo", strings.NewReader("hello")) + req.Header.Set("x-request-id", "test-123") + rr := httptest.NewRecorder() + http.HandlerFunc(srv.echoHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusOK) + } + if !strings.Contains(rr.Body.String(), "backend response") { + t.Errorf("expected backend response in body, got: %s", rr.Body.String()) + } + if rr.Header().Get("X-Color") != "blue" { + t.Errorf("X-Color = %q, want %q", rr.Header().Get("X-Color"), "blue") + } +} + +func TestEchoHandler_BackendError(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer backend.Close() + + srv := NewMockServer() + srv.config.BackendURL = []string{backend.URL} + + req, _ := http.NewRequest("POST", "/echo", strings.NewReader("hello")) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.echoHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusOK) + } + if !strings.Contains(rr.Body.String(), "response status code 500") { + t.Errorf("expected error message in body, got: %s", rr.Body.String()) + } +} + +func TestEchoHandler_BackendUnreachable(t *testing.T) { + srv := NewMockServer() + srv.config.BackendURL = []string{"http://127.0.0.1:1"} + + req, _ := http.NewRequest("POST", "/echo", strings.NewReader("hello")) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.echoHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusOK) + } + if !strings.Contains(rr.Body.String(), "call failed") { + t.Errorf("expected call failed in body, got: %s", rr.Body.String()) + } +} + +func TestEchoHandler_MultipleBackends(t *testing.T) { + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`"resp1"`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`"resp2"`)) + })) + defer backend2.Close() + + srv := NewMockServer() + srv.config.BackendURL = []string{backend1.URL, backend2.URL} + + req, _ := http.NewRequest("POST", "/echo", strings.NewReader("hello")) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.echoHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusOK) + } + if !strings.Contains(rr.Body.String(), "resp1") || !strings.Contains(rr.Body.String(), "resp2") { + t.Errorf("expected both backend responses, got: %s", rr.Body.String()) + } +} diff --git a/pkg/api/http/echows_test.go b/pkg/api/http/echows_test.go new file mode 100644 index 0000000..be73f84 --- /dev/null +++ b/pkg/api/http/echows_test.go @@ -0,0 +1,36 @@ +package http + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/websocket" +) + +func TestEchoWsHandler(t *testing.T) { + srv := NewMockServer() + srv.router.HandleFunc("/ws/echo", srv.echoWsHandler) + server := httptest.NewServer(srv.router) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/echo" + ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("websocket dial failed: %v", err) + } + defer ws.Close() + + msg := "hello websocket" + if err := ws.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil { + t.Fatalf("write failed: %v", err) + } + + _, p, err := ws.ReadMessage() + if err != nil { + t.Fatalf("read failed: %v", err) + } + if len(p) == 0 { + t.Error("received empty message") + } +} diff --git a/pkg/api/http/env_test.go b/pkg/api/http/env_test.go index 79c08bc..bbd430b 100644 --- a/pkg/api/http/env_test.go +++ b/pkg/api/http/env_test.go @@ -33,3 +33,17 @@ func TestEnvHandler(t *testing.T) { rr.Body.String(), expected) } } + +func TestEnvHandler_Actual(t *testing.T) { + srv := NewMockServer() + req, _ := http.NewRequest("GET", "/env", nil) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.envHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusOK) + } + if rr.Header().Get("Content-Type") != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q, want application/json", rr.Header().Get("Content-Type")) + } +} diff --git a/pkg/api/http/health_test.go b/pkg/api/http/health_test.go index d34625d..92743a4 100644 --- a/pkg/api/http/health_test.go +++ b/pkg/api/http/health_test.go @@ -3,6 +3,8 @@ package http import ( "net/http" "net/http/httptest" + "strings" + "sync/atomic" "testing" ) @@ -43,3 +45,67 @@ func TestReadyzHandler(t *testing.T) { status, http.StatusServiceUnavailable) } } + +func TestHealthzHandler_Healthy(t *testing.T) { + srv := NewMockServer() + atomic.StoreInt32(&healthy, 1) + defer atomic.StoreInt32(&healthy, 0) + + req, _ := http.NewRequest("GET", "/healthz", nil) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.healthzHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusOK) + } + if !strings.Contains(rr.Body.String(), "OK") { + t.Errorf("expected OK in body, got: %s", rr.Body.String()) + } +} + +func TestReadyzHandler_Ready(t *testing.T) { + srv := NewMockServer() + atomic.StoreInt32(&ready, 1) + defer atomic.StoreInt32(&ready, 0) + + req, _ := http.NewRequest("GET", "/readyz", nil) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.readyzHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusOK) + } +} + +func TestEnableReadyHandler(t *testing.T) { + srv := NewMockServer() + atomic.StoreInt32(&ready, 0) + + req, _ := http.NewRequest("POST", "/readyz/enable", nil) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.enableReadyHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusAccepted { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusAccepted) + } + if atomic.LoadInt32(&ready) != 1 { + t.Error("ready flag not set to 1") + } + atomic.StoreInt32(&ready, 0) +} + +func TestDisableReadyHandler(t *testing.T) { + srv := NewMockServer() + atomic.StoreInt32(&ready, 1) + + req, _ := http.NewRequest("POST", "/readyz/disable", nil) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.disableReadyHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusAccepted { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusAccepted) + } + if atomic.LoadInt32(&ready) != 0 { + t.Error("ready flag not set to 0") + } +} diff --git a/pkg/api/http/http_test.go b/pkg/api/http/http_test.go new file mode 100644 index 0000000..fd551cb --- /dev/null +++ b/pkg/api/http/http_test.go @@ -0,0 +1,23 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestVersionMiddleware(t *testing.T) { + srv := NewMockServer() + handler := versionMiddleware(http.HandlerFunc(srv.infoHandler)) + + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if req.Header.Get("X-API-Version") == "" { + t.Error("X-API-Version not set by middleware") + } + if req.Header.Get("X-API-Revision") == "" { + t.Error("X-API-Revision not set by middleware") + } +} diff --git a/pkg/api/http/status_test.go b/pkg/api/http/status_test.go index 3c8fda9..dd5dfa9 100644 --- a/pkg/api/http/status_test.go +++ b/pkg/api/http/status_test.go @@ -24,3 +24,28 @@ func TestStatusHandler(t *testing.T) { status, http.StatusNotFound) } } + +func TestStatusHandler_Various(t *testing.T) { + srv := NewMockServer() + srv.router.HandleFunc("/status/{code}", srv.statusHandler) + + cases := []struct { + code string + expected int + }{ + {"200", 200}, + {"201", 201}, + {"500", 500}, + {"503", 503}, + } + + for _, c := range cases { + req, _ := http.NewRequest("GET", "/status/"+c.code, nil) + rr := httptest.NewRecorder() + srv.router.ServeHTTP(rr, req) + + if rr.Code != c.expected { + t.Errorf("/status/%s: got %d, want %d", c.code, rr.Code, c.expected) + } + } +} diff --git a/pkg/api/http/store_test.go b/pkg/api/http/store_test.go index ba18e97..687adb0 100644 --- a/pkg/api/http/store_test.go +++ b/pkg/api/http/store_test.go @@ -1,6 +1,7 @@ package http import ( + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -80,3 +81,50 @@ func TestStoreReadHandler_PathTraversal(t *testing.T) { } } } + +func TestStoreWriteHandler(t *testing.T) { + srv := NewMockServer() + srv.config.DataPath = t.TempDir() + + payload := "hello world" + req, _ := http.NewRequest("POST", "/store", strings.NewReader(payload)) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.storeWriteHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusAccepted { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusAccepted) + } + + var result map[string]string + json.Unmarshal(rr.Body.Bytes(), &result) + if result["hash"] != hash(payload) { + t.Errorf("hash = %q, want %q", result["hash"], hash(payload)) + } +} + +func TestStoreWriteHandler_InvalidPath(t *testing.T) { + srv := NewMockServer() + srv.config.DataPath = "/nonexistent/path/that/does/not/exist" + + req, _ := http.NewRequest("POST", "/store", strings.NewReader("data")) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.storeWriteHandler).ServeHTTP(rr, req) + + if !strings.Contains(rr.Body.String(), "writing file failed") { + t.Errorf("expected write error, got: %s", rr.Body.String()) + } +} + +func TestStoreReadHandler_NotFound(t *testing.T) { + srv := NewMockServer() + srv.config.DataPath = t.TempDir() + srv.router.HandleFunc("/store/{hash}", srv.storeReadHandler) + + req, _ := http.NewRequest("GET", "/store/aabbccddee11223344556677889900aabbccddee", nil) + rr := httptest.NewRecorder() + srv.router.ServeHTTP(rr, req) + + if !strings.Contains(rr.Body.String(), "reading file failed") { + t.Errorf("expected read error, got: %s", rr.Body.String()) + } +} diff --git a/pkg/api/http/token_test.go b/pkg/api/http/token_test.go index f8d194e..1bc886e 100644 --- a/pkg/api/http/token_test.go +++ b/pkg/api/http/token_test.go @@ -34,3 +34,97 @@ func TestTokenHandler(t *testing.T) { t.Error("handler returned no token") } } + +func TestTokenGenerateHandler_EmptyBody(t *testing.T) { + srv := NewMockServer() + srv.config.JWTSecret = "test-secret" + + req, _ := http.NewRequest("POST", "/token", strings.NewReader("")) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.tokenGenerateHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusOK) + } + + var result TokenResponse + json.Unmarshal(rr.Body.Bytes(), &result) + if result.Token == "" { + t.Error("expected non-empty token") + } +} + +func TestTokenValidateHandler(t *testing.T) { + srv := NewMockServer() + srv.config.JWTSecret = "test-secret" + + // Generate a token first + genReq, _ := http.NewRequest("POST", "/token", strings.NewReader("test-user")) + genRR := httptest.NewRecorder() + http.HandlerFunc(srv.tokenGenerateHandler).ServeHTTP(genRR, genReq) + + var tokenResp TokenResponse + json.Unmarshal(genRR.Body.Bytes(), &tokenResp) + + t.Run("valid token", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/token/validate", nil) + req.Header.Set("Authorization", "Bearer "+tokenResp.Token) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.tokenValidateHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d, want %d", rr.Code, http.StatusOK) + } + var result TokenValidationResponse + json.Unmarshal(rr.Body.Bytes(), &result) + if result.TokenName != "test-user" { + t.Errorf("token_name = %q, want %q", result.TokenName, "test-user") + } + }) + + t.Run("missing authorization header", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/token/validate", nil) + rr := httptest.NewRecorder() + http.HandlerFunc(srv.tokenValidateHandler).ServeHTTP(rr, req) + + if !strings.Contains(rr.Body.String(), "authorization bearer header required") { + t.Errorf("unexpected body: %s", rr.Body.String()) + } + }) + + t.Run("malformed authorization header", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/token/validate", nil) + req.Header.Set("Authorization", "InvalidFormat") + rr := httptest.NewRecorder() + http.HandlerFunc(srv.tokenValidateHandler).ServeHTTP(rr, req) + + if !strings.Contains(rr.Body.String(), "authorization bearer header required") { + t.Errorf("unexpected body: %s", rr.Body.String()) + } + }) + + t.Run("invalid token", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/token/validate", nil) + req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiZmFrZSIsImlzcyI6InBvZGluZm8iLCJleHAiOjk5OTk5OTk5OTl9.invalidsig") + rr := httptest.NewRecorder() + http.HandlerFunc(srv.tokenValidateHandler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d", rr.Code) + } + }) + + t.Run("wrong signing secret", func(t *testing.T) { + otherSrv := NewMockServer() + otherSrv.config.JWTSecret = "different-secret" + + req, _ := http.NewRequest("GET", "/token/validate", nil) + req.Header.Set("Authorization", "Bearer "+tokenResp.Token) + rr := httptest.NewRecorder() + http.HandlerFunc(otherSrv.tokenValidateHandler).ServeHTTP(rr, req) + + if !strings.Contains(rr.Body.String(), "signature is invalid") { + t.Errorf("expected signature error, got: %s", rr.Body.String()) + } + }) +}