fix(cag): extract applyWarmup/acquireScheduledBackend helpers, add race-free tests

- 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
This commit is contained in:
Admin Bot
2026-09-27 18:57:04 +08:00
parent a731185588
commit d703ace2c6
4 changed files with 343 additions and 93 deletions
+131 -42
View File
@@ -1,10 +1,17 @@
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"
@@ -321,58 +328,140 @@ func TestDispatch_MethodCapabilityOverridesPOSTDefault(t *testing.T) {
}
}
// TestAuthenticate_MethodCapabilityOverrideDirect calls authenticate() directly
// (same-package access) with a non-nil jwtValidator so the auth gate is not bypassed.
// Two sub-tests verify:
// 1. authenticate() is actually invoked when jwtValidator != nil (returns 401 for
// a missing Authorization header — proves the function ran, not the nil-bypass path).
// 2. The method-level Auth.Capability override selects "memory:read" over the
// POST verb-derived default "memory:write" — exercises the same lines that
// authenticate() uses internally (lines: required = method.Auth.Capability).
// 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) {
// Validator with a dummy JWKS URL (never fetched — ValidateBearerToken is not
// reached in these sub-tests because we hit the auth-header guard first).
validator := auth.NewValidator("", "", "http://127.0.0.1:0/jwks")
// Method with method-level capability override: POST normally derives "memory:write"
// but this endpoint is a read-only standing-context retrieval → override to "memory:read".
method := &Method{
Verb: "POST",
UpstreamPath: "/memory/standing-context",
Auth: &Auth{Capability: "memory:read"},
// 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)
}
d := &Dispatcher{jwtValidator: validator}
// 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),
},
},
})
// Sub-test 1: authenticate() is called and returns 401 for a missing
// Authorization header — proves the function runs (not the nil-validator bypass).
t.Run("authenticate_called_returns_401_without_token", func(t *testing.T) {
// 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)
got := d.authenticate(w, r, "memory", method)
if got {
t.Error("expected authenticate() to return false for missing Authorization header")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 Unauthorized, got %d", w.Code)
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: prove that the capability override selects "memory:read" (not the
// verb-derived "memory:write") using the identical logic from authenticate().
t.Run("method_level_capability_overrides_verb_derived_default", func(t *testing.T) {
// Replicate the exact lines from authenticate() (router.go:119-121):
required := capabilityForVerb("memory", method.Verb) // POST → "memory:write"
if method.Auth != nil && method.Auth.Capability != "" {
required = method.Auth.Capability // override → "memory:read"
// 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".
},
},
},
},
},
}
if required != "memory:read" {
t.Errorf("method-level Auth.Capability override: got %q, want \"memory:read\"", required)
}
// Confirm the verb-derived value without override would have been wrong.
verbDefault := capabilityForVerb("memory", method.Verb)
if verbDefault != "memory:write" {
t.Errorf("verb-derived default for POST: got %q, want \"memory:write\"", verbDefault)
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())
}
})
}