chore: initial commit of Go API gateway
Baseline for the Kong replacement on api.riotpiao.com. Brings the working tree under version control for the first time: gateway source, the task board that drives the agent runs, test fixtures, and K8s manifests. Anchor the gateway ignore rule to the repo root. Unanchored, "gateway" also matched the cmd/gateway/ source directory, so the program entrypoint was excluded from every commit. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestBodySizeCapExact verifies that a body exactly at the cap is accepted.
|
||||
func TestBodySizeCapExact(t *testing.T) {
|
||||
upstreamReceived := false
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamReceived = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, "ok")
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
maxBodySize := int64(100)
|
||||
cfg := &config.Config{
|
||||
Routes: map[string]*config.Route{
|
||||
"capped-route": {
|
||||
Name: "capped-route",
|
||||
Upstream: config.Upstream{
|
||||
Address: upstreamAddr,
|
||||
MaxBodySize: maxBodySize,
|
||||
ConnectTimeout: 5 * time.Second,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 5 * time.Second,
|
||||
AuthRequired: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Send a body exactly at the cap
|
||||
body := strings.Repeat("a", int(maxBodySize))
|
||||
resp, err := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200 for body at cap, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if !upstreamReceived {
|
||||
t.Errorf("expected upstream to receive request, but it didn't")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodySizeCapOver verifies that a body over the cap is rejected with 413.
|
||||
func TestBodySizeCapOver(t *testing.T) {
|
||||
upstreamReceived := false
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamReceived = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
maxBodySize := int64(100)
|
||||
cfg := &config.Config{
|
||||
Routes: map[string]*config.Route{
|
||||
"capped-route": {
|
||||
Name: "capped-route",
|
||||
Upstream: config.Upstream{
|
||||
Address: upstreamAddr,
|
||||
MaxBodySize: maxBodySize,
|
||||
ConnectTimeout: 5 * time.Second,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 5 * time.Second,
|
||||
AuthRequired: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Send a body one byte over the cap
|
||||
body := strings.Repeat("a", int(maxBodySize)+1)
|
||||
resp, err := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("expected 413 for oversized body, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if upstreamReceived {
|
||||
t.Errorf("expected upstream to NOT receive request, but it did")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodySizeCapStreamingEnforcement verifies that the cap is enforced while reading.
|
||||
func TestBodySizeCapStreamingEnforcement(t *testing.T) {
|
||||
upstreamRequestsCount := 0
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamRequestsCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, "ok")
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
maxBodySize := int64(50)
|
||||
cfg := &config.Config{
|
||||
Routes: map[string]*config.Route{
|
||||
"capped-route": {
|
||||
Name: "capped-route",
|
||||
Upstream: config.Upstream{
|
||||
Address: upstreamAddr,
|
||||
MaxBodySize: maxBodySize,
|
||||
ConnectTimeout: 5 * time.Second,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 5 * time.Second,
|
||||
AuthRequired: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Create a body reader that's larger than the cap
|
||||
oversizeBody := strings.Repeat("x", int(maxBodySize+100))
|
||||
req, _ := http.NewRequest("POST", server.URL+"/test", strings.NewReader(oversizeBody))
|
||||
req.ContentLength = int64(len(oversizeBody))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should get 413
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("expected 413, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Upstream should never have been called
|
||||
if upstreamRequestsCount > 0 {
|
||||
t.Errorf("expected 0 upstream requests, got %d", upstreamRequestsCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodySizeCapWithoutContentLength verifies that bodies without Content-Length are still limited.
|
||||
func TestBodySizeCapWithoutContentLength(t *testing.T) {
|
||||
upstreamReceived := false
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamReceived = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
maxBodySize := int64(50)
|
||||
cfg := &config.Config{
|
||||
Routes: map[string]*config.Route{
|
||||
"capped-route": {
|
||||
Name: "capped-route",
|
||||
Upstream: config.Upstream{
|
||||
Address: upstreamAddr,
|
||||
MaxBodySize: maxBodySize,
|
||||
ConnectTimeout: 5 * time.Second,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 5 * time.Second,
|
||||
AuthRequired: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Create a request with streaming body (no Content-Length)
|
||||
// The body will exceed the cap when read
|
||||
oversizeBody := strings.Repeat("x", int(maxBodySize+100))
|
||||
req, _ := http.NewRequest("POST", server.URL+"/test", strings.NewReader(oversizeBody))
|
||||
// Explicitly set ContentLength to -1 (unknown)
|
||||
req.ContentLength = -1
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Without Content-Length header, the request passes initial check
|
||||
// But the upstream will receive a limited body
|
||||
if upstreamReceived {
|
||||
t.Logf("upstream received request with limited body (expected behavior)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodySizeCapNoLimit verifies that routes with zero cap (no limit) work.
|
||||
func TestBodySizeCapNoLimit(t *testing.T) {
|
||||
upstreamReceived := false
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamReceived = true
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
w.Header().Set("X-Body-Size", fmt.Sprintf("%d", len(body)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Routes: map[string]*config.Route{
|
||||
"unlimited-route": {
|
||||
Name: "unlimited-route",
|
||||
Upstream: config.Upstream{
|
||||
Address: upstreamAddr,
|
||||
MaxBodySize: 0, // No limit
|
||||
ConnectTimeout: 5 * time.Second,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 5 * time.Second,
|
||||
AuthRequired: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Send a large body
|
||||
largeBody := strings.Repeat("a", 10000)
|
||||
resp, err := http.Post(server.URL+"/test", "text/plain", strings.NewReader(largeBody))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if !upstreamReceived {
|
||||
t.Errorf("expected upstream to receive request")
|
||||
}
|
||||
|
||||
// Verify the body was fully received
|
||||
bodySizeStr := resp.Header.Get("X-Body-Size")
|
||||
if bodySizeStr != fmt.Sprintf("%d", len(largeBody)) {
|
||||
t.Errorf("expected body size %d, upstream saw %s", len(largeBody), bodySizeStr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodySizeCapRejectionLogged verifies that rejections are logged with reason.
|
||||
func TestBodySizeCapRejectionLogged(t *testing.T) {
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
maxBodySize := int64(100)
|
||||
cfg := &config.Config{
|
||||
Routes: map[string]*config.Route{
|
||||
"capped-route": {
|
||||
Name: "capped-route",
|
||||
Upstream: config.Upstream{
|
||||
Address: upstreamAddr,
|
||||
MaxBodySize: maxBodySize,
|
||||
ConnectTimeout: 5 * time.Second,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 5 * time.Second,
|
||||
AuthRequired: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Send an oversized body
|
||||
body := strings.Repeat("a", int(maxBodySize)+1)
|
||||
resp, _ := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
|
||||
resp.Body.Close()
|
||||
|
||||
// Verify rejection status
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("expected 413, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user