Files
Containr/app/backend/internal/api/gateway_proxy_test.go
T
Tomáš DvořákandDevin d5909172ae feat(gateway): /g/:slug proxy data plane with key auth and rate limits (#66)
The admin surface under /admin/gateway managed api_services and api_keys,
but no public route ever consumed them. Mounts /g/:slug and /g/:slug/* on
the root router (outside /api/v1 so route_prefix stays clean) and runs the
full pipeline: slug lookup -> X-API-Key/Bearer extraction -> prefix-indexed
bcrypt key auth -> allowed_service_ids scope -> per-minute buckets (key and
service caps) -> monthly usage_counters quotas -> gateway.ProxyRequest with
per-service timeout and upstream auth headers -> usage/metrics/incident
recording.

Live-verified: proxied GET/POST, 401/404/429 boundaries, counter upserts,
UPSTREAM_5XX incidents. Unit tests cover URL building, the rpm bucket, key
extraction, and the service allowlist.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 00:29:57 +02:00

82 lines
2.3 KiB
Go

package api
import (
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestGatewayRateAllow(t *testing.T) {
now := time.Date(2026, 10, 15, 12, 0, 30, 0, time.UTC)
for i := 0; i < 3; i++ {
if !gatewayRateAllow("test:a", 3, now) {
t.Fatalf("call %d within limit rejected", i+1)
}
}
if gatewayRateAllow("test:a", 3, now) {
t.Fatal("call beyond limit allowed")
}
if !gatewayRateAllow("test:a", 3, now.Add(time.Minute)) {
t.Fatal("new minute window still rejected")
}
if !gatewayRateAllow("test:unlimited", 0, now) {
t.Fatal("non-positive limit should disable the check")
}
}
func TestExtractGatewayKey(t *testing.T) {
mkctx := func(headers map[string]string) *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/g/x", nil)
for k, v := range headers {
c.Request.Header.Set(k, v)
}
return c
}
if got := extractGatewayKey(mkctx(map[string]string{"X-API-Key": "ap_abc"})); got != "ap_abc" {
t.Fatalf("X-API-Key not extracted: %q", got)
}
if got := extractGatewayKey(mkctx(map[string]string{"Authorization": "Bearer ap_def"})); got != "ap_def" {
t.Fatalf("Bearer not extracted: %q", got)
}
// X-API-Key wins when both are present.
if got := extractGatewayKey(mkctx(map[string]string{
"X-API-Key": "ap_first",
"Authorization": "Bearer ap_second",
})); got != "ap_first" {
t.Fatalf("X-API-Key should take precedence: %q", got)
}
if got := extractGatewayKey(mkctx(map[string]string{})); got != "" {
t.Fatalf("expected empty key, got %q", got)
}
}
func TestKeyAllowsService(t *testing.T) {
svcID := "11111111-2222-3333-4444-555555555555"
other := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
cases := []struct {
name string
allowed string
want bool
}{
{"empty list permits all", "[]", true},
{"blank permits all", "", true},
{"invalid json permits all", "not-json", true},
{"listed service allowed", `["` + svcID + `"]`, true},
{"unlisted service denied", `["` + other + `"]`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
k := &gatewayKey{AllowedServiceIDs: tc.allowed}
if got := keyAllowsService(k, svcID); got != tc.want {
t.Fatalf("keyAllowsService(%q) = %v, want %v", tc.allowed, got, tc.want)
}
})
}
}