package proxy import ( "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "forgejo.riotpiao.com/rock/homelab-frontend/internal/auth" "forgejo.riotpiao.com/rock/homelab-frontend/internal/config" ) // fakeJWT creates a JWT-shaped string (header.payload.signature) with given claims. // Not cryptographically signed — used only with stubbed validators. func fakeJWT(claims map[string]interface{}) string { header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) payload, _ := json.Marshal(claims) payloadB64 := base64.RawURLEncoding.EncodeToString(payload) return header + "." + payloadB64 + ".fakesig" } // stubJWTValidator returns claims from a pre-set map keyed by token. type stubJWTValidator struct { tokens map[string]map[string]interface{} } func (s *stubJWTValidator) ValidateBearerToken(authHeader string) (map[string]interface{}, error) { token := strings.TrimPrefix(authHeader, "Bearer ") if claims, ok := s.tokens[token]; ok { return claims, nil } return nil, fmt.Errorf("invalid token") } func (s *stubJWTValidator) CheckPermissions(claims map[string]interface{}, required ...string) bool { return true } // We can't use stubJWTValidator directly because Handler expects *auth.Validator. // Instead, test via the endpoint with a real JWKS server or test the helpers directly. func TestScopeSubset(t *testing.T) { tests := []struct { requested string allowed []string want bool }{ {"memory:read", []string{"llm:inference", "memory:read"}, true}, {"memory:read memory:write", []string{"memory:read", "memory:write"}, true}, {"memory:write", []string{"memory:read"}, false}, {"admin:*", []string{"memory:read"}, false}, {"anything", []string{"*"}, true}, {"", []string{"memory:read"}, true}, {"memory:read", []string{}, false}, } for _, tt := range tests { got := scopeSubset(tt.requested, tt.allowed) if got != tt.want { t.Errorf("scopeSubset(%q, %v) = %v, want %v", tt.requested, tt.allowed, got, tt.want) } } } func TestExtractRolesFromJWT(t *testing.T) { token := fakeJWT(map[string]interface{}{ "roles": []interface{}{"llm:inference", "memory:read"}, "sub": "test-user", }) roles, err := extractRolesFromJWT(token) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(roles) != 2 || roles[0] != "llm:inference" || roles[1] != "memory:read" { t.Errorf("roles = %v, want [llm:inference memory:read]", roles) } } func TestExtractRolesFromJWT_InvalidFormat(t *testing.T) { _, err := extractRolesFromJWT("not-a-jwt") if err == nil { t.Error("expected error for invalid JWT") } } func TestExtractRolesFromJWT_NoRoles(t *testing.T) { token := fakeJWT(map[string]interface{}{"sub": "user"}) roles, err := extractRolesFromJWT(token) if err != nil { t.Fatalf("unexpected error: %v", err) } if roles != nil { t.Errorf("expected nil roles, got %v", roles) } } func TestHandleAuthExchange_WrongMethod(t *testing.T) { h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{}) h.jwtValidator = auth.NewValidator("", "", "") r := httptest.NewRequest("GET", "/auth/exchange", nil) w := httptest.NewRecorder() h.handleAuthExchange(w, r) if w.Code != 405 { t.Errorf("expected 405, got %d", w.Code) } } func TestHandleAuthExchange_NotConfigured(t *testing.T) { h := &Handler{ config: &config.Config{}, routes: make(map[string]*Route), transports: make(map[string]*http.Transport), } body := `{"subject_token":"x","client_id":"y","client_secret":"z"}` r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader(body)) w := httptest.NewRecorder() h.handleAuthExchange(w, r) if w.Code != 503 { t.Errorf("expected 503, got %d", w.Code) } } func TestHandleAuthExchange_MissingFields(t *testing.T) { h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{}) h.jwtValidator = auth.NewValidator("", "", "") tests := []struct { name string body string }{ {"missing subject", `{"client_id":"x","client_secret":"y"}`}, {"missing client_id", `{"subject_token":"x","client_secret":"y"}`}, {"missing client_secret", `{"subject_token":"x","client_id":"y"}`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader(tt.body)) w := httptest.NewRecorder() h.handleAuthExchange(w, r) if w.Code != 400 { t.Errorf("expected 400, got %d", w.Code) } }) } } func TestHandleAuthExchange_InvalidJSON(t *testing.T) { h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{}) h.jwtValidator = auth.NewValidator("", "", "") r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader("not json")) w := httptest.NewRecorder() h.handleAuthExchange(w, r) if w.Code != 400 { t.Errorf("expected 400, got %d", w.Code) } } func TestHandleAuthExchange_ScopeEscalation(t *testing.T) { // Test scopeSubset directly since full integration needs JWKS if scopeSubset("admin:delete", []string{"memory:read", "memory:write"}) { t.Error("scope escalation should be denied") } if !scopeSubset("memory:read", []string{"memory:read", "memory:write"}) { t.Error("valid scope should be allowed") } } func TestClaimStr(t *testing.T) { claims := map[string]interface{}{"sub": "user-1", "num": 42, "nil": nil} if got := claimStr(claims, "sub"); got != "user-1" { t.Errorf("claimStr(sub) = %q, want user-1", got) } if got := claimStr(claims, "num"); got != "" { t.Errorf("claimStr(num) = %q, want empty", got) } if got := claimStr(claims, "nil"); got != "" { t.Errorf("claimStr(nil) = %q, want empty", got) } if got := claimStr(claims, "missing"); got != "" { t.Errorf("claimStr(missing) = %q, want empty", got) } } func TestClaimStrSlice(t *testing.T) { claims := map[string]interface{}{ "roles": []interface{}{"a", "b", "", nil, 42}, "empty": []interface{}{}, "str": "not-a-slice", } if got := claimStrSlice(claims, "roles"); len(got) != 2 || got[0] != "a" || got[1] != "b" { t.Errorf("claimStrSlice(roles) = %v, want [a b]", got) } if got := claimStrSlice(claims, "empty"); len(got) != 0 { t.Errorf("claimStrSlice(empty) = %v, want empty", got) } if got := claimStrSlice(claims, "str"); got != nil { t.Errorf("claimStrSlice(str) = %v, want nil", got) } if got := claimStrSlice(claims, "missing"); got != nil { t.Errorf("claimStrSlice(missing) = %v, want nil", got) } }