- proxy.go: extract applyWarmup + acquireScheduledBackend to reduce ServeHTTP complexity - proxy_test.go: TestProxyQueueFull, TestProxyCacheHitLogging (AC5/AC6/AC7 HTTP-level coverage) - scheduler_test.go: TestBackendAcquireCancelledContext + runtime.Gosched spin-wait (no time.Sleep) - router_test.go: TestAuthenticate_MethodCapabilityOverrideDirect with real RS256 JWT + JWKS server
468 lines
14 KiB
Go
468 lines
14 KiB
Go
package serviceadapter
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
|
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
|
)
|
|
|
|
// stubValidator implements the minimum interface for testing auth.
|
|
// Real auth.Validator needs JWKS — we test the dispatcher logic, not JWT crypto.
|
|
|
|
func newTestRegistry(adapters ...ServiceAdapter) *Registry {
|
|
r := NewRegistry(nil)
|
|
for i := range adapters {
|
|
_ = r.Add(&adapters[i])
|
|
}
|
|
return r
|
|
}
|
|
|
|
func sqsAdapter(authRequired bool) ServiceAdapter {
|
|
return ServiceAdapter{
|
|
Name: "sqs",
|
|
ServiceName: "sqs",
|
|
Spec: Spec{
|
|
ServiceName: "sqs",
|
|
Upstream: Upstream{URL: "http://localhost:9999", TimeoutSeconds: 5},
|
|
Auth: Auth{Required: authRequired},
|
|
Resources: []Resource{
|
|
{
|
|
Name: "list-queues",
|
|
Methods: []Method{
|
|
{Verb: "GET", UpstreamPath: "/sqs/queues"},
|
|
},
|
|
},
|
|
{
|
|
Name: "send-message",
|
|
Methods: []Method{
|
|
{Verb: "POST", UpstreamPath: "/sqs/send"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func memoryAdapter() ServiceAdapter {
|
|
return ServiceAdapter{
|
|
Name: "memory",
|
|
ServiceName: "memory",
|
|
Spec: Spec{
|
|
ServiceName: "memory",
|
|
Upstream: Upstream{URL: "http://localhost:8888", TimeoutSeconds: 5},
|
|
Auth: Auth{Required: false},
|
|
Resources: []Resource{
|
|
{
|
|
Name: "skills",
|
|
Methods: []Method{
|
|
{Verb: "GET", UpstreamPath: "/memory/skills"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestDispatch_MissingXService(t *testing.T) {
|
|
d := NewDispatcher(newTestRegistry(), nil)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
|
|
d.Dispatch(w, r)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_UnknownService(t *testing.T) {
|
|
d := NewDispatcher(newTestRegistry(), nil)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("X-Service", "nonexistent")
|
|
r.Header.Set("X-Resource", "foo")
|
|
|
|
d.Dispatch(w, r)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_MissingXResource(t *testing.T) {
|
|
d := NewDispatcher(newTestRegistry(memoryAdapter()), nil)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("X-Service", "memory")
|
|
|
|
d.Dispatch(w, r)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_UnknownResource(t *testing.T) {
|
|
d := NewDispatcher(newTestRegistry(memoryAdapter()), nil)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("X-Service", "memory")
|
|
r.Header.Set("X-Resource", "nonexistent")
|
|
|
|
d.Dispatch(w, r)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_WrongHTTPVerb(t *testing.T) {
|
|
d := NewDispatcher(newTestRegistry(memoryAdapter()), nil)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/", nil)
|
|
r.Header.Set("X-Service", "memory")
|
|
r.Header.Set("X-Resource", "skills")
|
|
|
|
d.Dispatch(w, r)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_AuthRequired_NoToken(t *testing.T) {
|
|
// Use nil validator — auth required but no validator means 401
|
|
// Actually with nil validator, auth is skipped. Use a real scenario.
|
|
// We need a mock validator. For now test that auth.Required=false passes through.
|
|
// The real auth test needs the full JWKS setup which is an integration test.
|
|
|
|
// Test: auth required, no validator configured = passes through (defense in depth via NetworkPolicy)
|
|
d := NewDispatcher(newTestRegistry(sqsAdapter(true)), nil)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("X-Service", "sqs")
|
|
r.Header.Set("X-Resource", "list-queues")
|
|
|
|
d.Dispatch(w, r)
|
|
|
|
// With nil validator, auth check is skipped — request reaches upstream (which will fail since localhost:9999 is down)
|
|
// The key assertion: it did NOT return 401/403, it tried to proxy
|
|
if w.Code == http.StatusUnauthorized || w.Code == http.StatusForbidden {
|
|
t.Errorf("expected proxy attempt (not auth rejection), got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_AuthNotRequired_NoToken(t *testing.T) {
|
|
// Start a test upstream
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path})
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
adapter := memoryAdapter()
|
|
adapter.Spec.Upstream.URL = upstream.URL
|
|
|
|
d := NewDispatcher(newTestRegistry(adapter), nil)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("X-Service", "memory")
|
|
r.Header.Set("X-Resource", "skills")
|
|
|
|
d.Dispatch(w, r)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var body map[string]string
|
|
json.NewDecoder(w.Body).Decode(&body)
|
|
if body["path"] != "/memory/skills" {
|
|
t.Errorf("expected upstream path /memory/skills, got %s", body["path"])
|
|
}
|
|
}
|
|
|
|
func TestDispatch_PassThroughHeaders(t *testing.T) {
|
|
var receivedAuth string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
receivedAuth = r.Header.Get("Authorization")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
adapter := memoryAdapter()
|
|
adapter.Spec.Upstream.URL = upstream.URL
|
|
|
|
d := NewDispatcher(newTestRegistry(adapter), nil)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("X-Service", "memory")
|
|
r.Header.Set("X-Resource", "skills")
|
|
r.Header.Set("Authorization", "Bearer some-jwt")
|
|
|
|
d.Dispatch(w, r)
|
|
|
|
if receivedAuth != "Bearer some-jwt" {
|
|
t.Errorf("Authorization header not passed through, got %q", receivedAuth)
|
|
}
|
|
}
|
|
|
|
func TestCapabilityForVerb(t *testing.T) {
|
|
tests := []struct {
|
|
service string
|
|
verb string
|
|
want string
|
|
}{
|
|
{"sqs", "GET", "sqs:read"},
|
|
{"sqs", "HEAD", "sqs:read"},
|
|
{"sqs", "OPTIONS", "sqs:read"},
|
|
{"sqs", "POST", "sqs:write"},
|
|
{"sqs", "PUT", "sqs:write"},
|
|
{"sqs", "DELETE", "sqs:write"},
|
|
{"sqs", "PATCH", "sqs:write"},
|
|
{"memory", "GET", "memory:read"},
|
|
{"memory", "POST", "memory:write"},
|
|
{"s3", "GET", "s3:read"},
|
|
{"s3", "PUT", "s3:write"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := capabilityForVerb(tt.service, tt.verb)
|
|
if got != tt.want {
|
|
t.Errorf("capabilityForVerb(%s, %s) = %s, want %s", tt.service, tt.verb, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDispatch_IdentityHeadersNotSet_WhenNoAuth(t *testing.T) {
|
|
var gotUser, gotVerified string
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotUser = r.Header.Get(identity.HeaderUser)
|
|
gotVerified = r.Header.Get(identity.HeaderAuthVerified)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
adapter := memoryAdapter()
|
|
adapter.Spec.Upstream.URL = upstream.URL
|
|
|
|
d := NewDispatcher(newTestRegistry(adapter), nil)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("X-Service", "memory")
|
|
r.Header.Set("X-Resource", "skills")
|
|
|
|
d.Dispatch(w, r)
|
|
|
|
if gotUser != "" {
|
|
t.Errorf("X-Forwarded-User should not be set without auth, got %q", gotUser)
|
|
}
|
|
if gotVerified != "" {
|
|
t.Errorf("X-Auth-Verified should not be set without auth, got %q", gotVerified)
|
|
}
|
|
}
|
|
|
|
// TestDispatch_MethodCapabilityOverridesPOSTDefault verifies that a method-level
|
|
// Auth.Capability (e.g. "memory:read" on a POST standing-context endpoint) is
|
|
// respected instead of the verb-derived capability ("memory:write").
|
|
// Because auth.Validator is a concrete struct requiring real JWKS, we pass nil
|
|
// jwtValidator so auth is bypassed and assert only that routing succeeds (not 403).
|
|
func TestDispatch_MethodCapabilityOverridesPOSTDefault(t *testing.T) {
|
|
// Upstream captures the request so we can verify it was routed.
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
// Memory-like adapter: POST standing-context with method-level Auth.Capability=memory:read.
|
|
adapter := ServiceAdapter{
|
|
Name: "memory",
|
|
ServiceName: "memory",
|
|
Spec: Spec{
|
|
ServiceName: "memory",
|
|
Upstream: Upstream{URL: upstream.URL, TimeoutSeconds: 5},
|
|
Auth: Auth{Required: false},
|
|
Resources: []Resource{
|
|
{
|
|
Name: "standing-context",
|
|
Methods: []Method{
|
|
{
|
|
Verb: "POST",
|
|
UpstreamPath: "/memory/standing-context",
|
|
// Override: read capability even though verb is POST.
|
|
Auth: &Auth{Capability: "memory:read"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// nil jwtValidator: auth.Required=false + nil validator → auth skipped entirely.
|
|
d := NewDispatcher(newTestRegistry(adapter), nil)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/", nil)
|
|
r.Header.Set("X-Service", "memory")
|
|
r.Header.Set("X-Resource", "standing-context")
|
|
|
|
d.Dispatch(w, r)
|
|
|
|
// Must NOT be 403: the method-level memory:read override is not checked
|
|
// against verb-derived memory:write (auth is bypassed with nil validator).
|
|
// Must be 200 routed to real upstream.
|
|
if w.Code == http.StatusForbidden {
|
|
t.Errorf("got 403 Forbidden; memory:read override should not produce forbidden (auth bypassed)")
|
|
}
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200 from upstream, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestAuthenticate_MethodCapabilityOverrideDirect calls d.Dispatch() with a real
|
|
// signed RS256 JWT so authenticate() executes end-to-end and the capability-override
|
|
// branch (router.go lines 124-130) is reached and covered.
|
|
//
|
|
// Sub-test 1: token has "memory:read", method.Auth.Capability="memory:read" → 200.
|
|
// Sub-test 2: token has "memory:read", no method override → POST derives
|
|
//
|
|
// "memory:write" → CheckPermissions fails → 403.
|
|
func TestAuthenticate_MethodCapabilityOverrideDirect(t *testing.T) {
|
|
// Generate an RSA key pair for signing test JWTs.
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
|
}
|
|
|
|
// Build minimal JWKS document from the RSA public key.
|
|
nBytes := privateKey.PublicKey.N.Bytes()
|
|
eBig := new(big.Int).SetInt64(int64(privateKey.PublicKey.E))
|
|
eBytes := eBig.Bytes()
|
|
jwksDoc, _ := json.Marshal(map[string]interface{}{
|
|
"keys": []map[string]interface{}{
|
|
{
|
|
"kty": "RSA",
|
|
"use": "sig",
|
|
"kid": "test-key",
|
|
"alg": "RS256",
|
|
"n": base64.RawURLEncoding.EncodeToString(nBytes),
|
|
"e": base64.RawURLEncoding.EncodeToString(eBytes),
|
|
},
|
|
},
|
|
})
|
|
|
|
// Serve JWKS from a local test server.
|
|
jwksSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write(jwksDoc)
|
|
}))
|
|
defer jwksSrv.Close()
|
|
|
|
// Upstream that returns 200 when authenticate() passes.
|
|
upstreamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer upstreamSrv.Close()
|
|
|
|
// signJWT returns a signed RS256 token with the given permissions slice.
|
|
signJWT := func(permissions []string) string {
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
|
"iss": "https://authentik.riotpiao.com/application/o/test/",
|
|
"aud": "test-audience",
|
|
"sub": "test-user",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"iat": time.Now().Unix(),
|
|
"permissions": permissions,
|
|
})
|
|
tok.Header["kid"] = "test-key"
|
|
signed, err := tok.SignedString(privateKey)
|
|
if err != nil {
|
|
t.Fatalf("sign JWT: %v", err)
|
|
}
|
|
return signed
|
|
}
|
|
|
|
validator := auth.NewValidator("", "", jwksSrv.URL)
|
|
|
|
// Sub-test 1: method.Auth.Capability="memory:read"; token has "memory:read".
|
|
// authenticate() must execute the override branch and pass → upstream returns 200.
|
|
t.Run("capability_override_match_reaches_upstream", func(t *testing.T) {
|
|
adapter := ServiceAdapter{
|
|
Name: "memory",
|
|
ServiceName: "memory",
|
|
Spec: Spec{
|
|
ServiceName: "memory",
|
|
Upstream: Upstream{URL: upstreamSrv.URL, TimeoutSeconds: 5},
|
|
Auth: Auth{Required: true},
|
|
Resources: []Resource{
|
|
{
|
|
Name: "standing-context",
|
|
Methods: []Method{
|
|
{
|
|
Verb: "POST",
|
|
UpstreamPath: "/memory/standing-context",
|
|
Auth: &Auth{Capability: "memory:read"}, // override
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
d := NewDispatcher(newTestRegistry(adapter), validator)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/", nil)
|
|
r.Header.Set("X-Service", "memory")
|
|
r.Header.Set("X-Resource", "standing-context")
|
|
r.Header.Set("Authorization", "Bearer "+signJWT([]string{"memory:read"}))
|
|
d.Dispatch(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200 (override memory:read match), got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
// Sub-test 2: no method-level Auth override; POST → capabilityForVerb → "memory:write".
|
|
// Token has only "memory:read" → CheckPermissions fails → 403.
|
|
t.Run("verb_derived_capability_mismatch_returns_403", func(t *testing.T) {
|
|
adapter := ServiceAdapter{
|
|
Name: "memory",
|
|
ServiceName: "memory",
|
|
Spec: Spec{
|
|
ServiceName: "memory",
|
|
Upstream: Upstream{URL: upstreamSrv.URL, TimeoutSeconds: 5},
|
|
Auth: Auth{Required: true},
|
|
Resources: []Resource{
|
|
{
|
|
Name: "data",
|
|
Methods: []Method{
|
|
{
|
|
Verb: "POST",
|
|
UpstreamPath: "/memory/data",
|
|
// No method-level Auth: POST derives "memory:write".
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
d := NewDispatcher(newTestRegistry(adapter), validator)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/", nil)
|
|
r.Header.Set("X-Service", "memory")
|
|
r.Header.Set("X-Resource", "data")
|
|
r.Header.Set("Authorization", "Bearer "+signJWT([]string{"memory:read"}))
|
|
d.Dispatch(w, r)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected 403 (write required, read only in token), got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|