package proxy import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/Riotpiaole/homelab-frontend/internal/config" ) // RFC 9457 Problem Details type ProblemDetail struct { Type string `json:"type"` Title string `json:"title"` Status int `json:"status"` Detail string `json:"detail"` Instance string `json:"instance,omitempty"` Extra map[string]interface{} `json:"-"` } // UnmarshalJSON allows capturing extra fields func (p *ProblemDetail) UnmarshalJSON(data []byte) error { type Alias ProblemDetail aux := &struct { *Alias }{ Alias: (*Alias)(p), } if err := json.Unmarshal(data, &aux); err != nil { return err } // Capture extra fields var raw map[string]interface{} json.Unmarshal(data, &raw) extra := make(map[string]interface{}) for k, v := range raw { if k != "type" && k != "title" && k != "status" && k != "detail" && k != "instance" { extra[k] = v } } p.Extra = extra return nil } // TestUnknownModelReturns4xx verifies unknown model returns client error func TestUnknownModelReturns4xx(t *testing.T) { cfg := &config.Config{ Models: map[string]*config.ModelUpstream{ "reasoning": { Name: "reasoning", Address: "reasoning-predictor:80", Path: "/v1/chat/completions", }, "ornith:35b": { Name: "ornith:35b", Address: "ornith-predictor:80", Path: "/v1/chat/completions", }, }, Routes: make(map[string]*config.Route), } handler := New(cfg) defer handler.Close() server := httptest.NewServer(handler) defer server.Close() resp, err := http.Post( server.URL+"/v1/chat/completions", "application/json", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[]}`)), ) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() // Verify 4xx status if resp.StatusCode < 400 || resp.StatusCode >= 500 { t.Errorf("expected 4xx status for unknown model, got %d", resp.StatusCode) } // Verify RFC 9457 content type ct := resp.Header.Get("Content-Type") if !strings.Contains(ct, "application/problem+json") { t.Errorf("expected content-type application/problem+json, got %s", ct) } // Verify response is valid problem detail var prob ProblemDetail body, _ := io.ReadAll(resp.Body) if err := json.Unmarshal(body, &prob); err != nil { t.Errorf("response is not valid JSON: %v", err) } if prob.Status == 0 { t.Errorf("expected status in problem detail") } if prob.Title == "" { t.Errorf("expected title in problem detail") } } // TestUnknownModelEnumeratesValidModels verifies all models are listed func TestUnknownModelEnumeratesValidModels(t *testing.T) { cfg := &config.Config{ Models: map[string]*config.ModelUpstream{ "reasoning": { Name: "reasoning", Address: "reasoning-predictor:80", }, "ornith:35b": { Name: "ornith:35b", Address: "ornith-predictor:80", }, "qwen2.5:3b-instruct": { Name: "qwen2.5:3b-instruct", Address: "ornith-predictor:80", }, }, Routes: make(map[string]*config.Route), } handler := New(cfg) defer handler.Close() server := httptest.NewServer(handler) defer server.Close() resp, err := http.Post( server.URL+"/v1/chat/completions", "application/json", bytes.NewReader([]byte(`{"model":"unknown","messages":[]}`)), ) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() var prob map[string]interface{} json.NewDecoder(resp.Body).Decode(&prob) // Check for valid_models field (as an extra field beyond RFC 9457) validModels, hasModels := prob["valid_models"] if !hasModels { t.Errorf("expected valid_models field in problem detail") return } models := validModels.([]interface{}) if len(models) != 3 { t.Errorf("expected 3 models in valid_models, got %d", len(models)) } modelNames := make(map[string]bool) for _, m := range models { modelNames[m.(string)] = true } expectedModels := []string{"reasoning", "ornith:35b", "qwen2.5:3b-instruct"} for _, expected := range expectedModels { if !modelNames[expected] { t.Errorf("expected model %s in valid_models", expected) } } } // TestMissingModelFieldReturns4xx verifies missing model field is client error func TestMissingModelFieldReturns4xx(t *testing.T) { cfg := &config.Config{ Models: map[string]*config.ModelUpstream{ "reasoning": { Name: "reasoning", Address: "reasoning-predictor:80", }, }, Routes: make(map[string]*config.Route), } handler := New(cfg) defer handler.Close() server := httptest.NewServer(handler) defer server.Close() // Request with no model field resp, err := http.Post( server.URL+"/v1/chat/completions", "application/json", bytes.NewReader([]byte(`{"messages":[]}`)), ) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode < 400 || resp.StatusCode >= 500 { t.Errorf("expected 4xx for missing model, got %d", resp.StatusCode) } ct := resp.Header.Get("Content-Type") if !strings.Contains(ct, "application/problem+json") { t.Errorf("expected problem+json for missing model") } // Verify body does not contain the request body, _ := io.ReadAll(resp.Body) if strings.Contains(string(body), "messages") { t.Errorf("response should not echo request body") } } // TestNullModelFieldReturns4xx verifies null model is client error func TestNullModelFieldReturns4xx(t *testing.T) { cfg := &config.Config{ Models: map[string]*config.ModelUpstream{ "reasoning": { Name: "reasoning", Address: "reasoning-predictor:80", }, }, Routes: make(map[string]*config.Route), } handler := New(cfg) defer handler.Close() server := httptest.NewServer(handler) defer server.Close() // Request with null model resp, err := http.Post( server.URL+"/v1/chat/completions", "application/json", bytes.NewReader([]byte(`{"model":null,"messages":[]}`)), ) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode < 400 || resp.StatusCode >= 500 { t.Errorf("expected 4xx for null model, got %d", resp.StatusCode) } ct := resp.Header.Get("Content-Type") if !strings.Contains(ct, "application/problem+json") { t.Errorf("expected problem+json for null model") } } // TestEmptyModelFieldReturns4xx verifies empty model string is client error func TestEmptyModelFieldReturns4xx(t *testing.T) { cfg := &config.Config{ Models: map[string]*config.ModelUpstream{ "reasoning": { Name: "reasoning", Address: "reasoning-predictor:80", }, }, Routes: make(map[string]*config.Route), } handler := New(cfg) defer handler.Close() server := httptest.NewServer(handler) defer server.Close() // Request with empty model string resp, err := http.Post( server.URL+"/v1/chat/completions", "application/json", bytes.NewReader([]byte(`{"model":"","messages":[]}`)), ) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode < 400 || resp.StatusCode >= 500 { t.Errorf("expected 4xx for empty model, got %d", resp.StatusCode) } } // TestInvalidJSONIsDistinguishableError verifies invalid JSON is separate from unknown model func TestInvalidJSONIsDistinguishableError(t *testing.T) { cfg := &config.Config{ Models: map[string]*config.ModelUpstream{ "reasoning": { Name: "reasoning", Address: "reasoning-predictor:80", }, }, Routes: make(map[string]*config.Route), } handler := New(cfg) defer handler.Close() server := httptest.NewServer(handler) defer server.Close() // Request with invalid JSON resp, err := http.Post( server.URL+"/v1/chat/completions", "application/json", bytes.NewReader([]byte(`not json`)), ) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode < 400 || resp.StatusCode >= 500 { t.Errorf("expected 4xx for invalid JSON, got %d", resp.StatusCode) } var prob map[string]interface{} json.NewDecoder(resp.Body).Decode(&prob) // Invalid JSON error should mention JSON parsing, not model detail := prob["detail"].(string) if !strings.Contains(strings.ToLower(detail), "json") { t.Errorf("expected detail to mention JSON for invalid JSON error") } } // TestUnknownModelDoesNotContactUpstream verifies no upstream call is made func TestUnknownModelDoesNotContactUpstream(t *testing.T) { upstreamCalled := false upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstreamCalled = true w.WriteHeader(http.StatusOK) })) defer upstreamServer.Close() upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://") cfg := &config.Config{ Models: map[string]*config.ModelUpstream{ "reasoning": { Name: "reasoning", Address: upstreamAddr, }, }, Routes: make(map[string]*config.Route), } handler := New(cfg) defer handler.Close() server := httptest.NewServer(handler) defer server.Close() _, _ = http.Post( server.URL+"/v1/chat/completions", "application/json", bytes.NewReader([]byte(`{"model":"unknown","messages":[]}`)), ) if upstreamCalled { t.Errorf("upstream should not be called for unknown model") } } // TestUnknownModelLogsReason verifies rejection is logged func TestUnknownModelLogsReason(t *testing.T) { cfg := &config.Config{ Models: map[string]*config.ModelUpstream{ "reasoning": { Name: "reasoning", Address: "reasoning-predictor:80", }, }, Routes: make(map[string]*config.Route), } handler := New(cfg) defer handler.Close() server := httptest.NewServer(handler) defer server.Close() // This test verifies logging behavior by checking the handler's logger output // In a real scenario, you'd capture stderr or use a test logger _, _ = http.Post( server.URL+"/v1/chat/completions", "application/json", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[]}`)), ) // Logging is verified by checking that no panic occurs // and the request completes successfully } // TestMissingModelAndUnknownModelBothReturn4xx verifies consistent error class func TestMissingModelAndUnknownModelBothReturn4xx(t *testing.T) { cfg := &config.Config{ Models: map[string]*config.ModelUpstream{ "reasoning": { Name: "reasoning", Address: "reasoning-predictor:80", }, }, Routes: make(map[string]*config.Route), } handler := New(cfg) defer handler.Close() server := httptest.NewServer(handler) defer server.Close() // Test missing model resp1, err := http.Post( server.URL+"/v1/chat/completions", "application/json", bytes.NewReader([]byte(`{"messages":[]}`)), ) if err != nil { t.Fatalf("request failed: %v", err) } resp1.Body.Close() // Test unknown model resp2, err := http.Post( server.URL+"/v1/chat/completions", "application/json", bytes.NewReader([]byte(`{"model":"unknown","messages":[]}`)), ) if err != nil { t.Fatalf("request failed: %v", err) } resp2.Body.Close() // Both should be in 4xx range if resp1.StatusCode < 400 || resp1.StatusCode >= 500 { t.Errorf("expected 4xx for missing model, got %d", resp1.StatusCode) } if resp2.StatusCode < 400 || resp2.StatusCode >= 500 { t.Errorf("expected 4xx for unknown model, got %d", resp2.StatusCode) } // Both should be problem+json ct1 := resp1.Header.Get("Content-Type") ct2 := resp2.Header.Get("Content-Type") if !strings.Contains(ct1, "application/problem+json") { t.Errorf("expected problem+json for missing model") } if !strings.Contains(ct2, "application/problem+json") { t.Errorf("expected problem+json for unknown model") } } // TestProblemDetailHasRequiredFields verifies RFC 9457 compliance func TestProblemDetailHasRequiredFields(t *testing.T) { cfg := &config.Config{ Models: map[string]*config.ModelUpstream{ "reasoning": { Name: "reasoning", Address: "reasoning-predictor:80", }, }, Routes: make(map[string]*config.Route), } handler := New(cfg) defer handler.Close() server := httptest.NewServer(handler) defer server.Close() resp, err := http.Post( server.URL+"/v1/chat/completions", "application/json", bytes.NewReader([]byte(`{"model":"unknown","messages":[]}`)), ) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() var prob map[string]interface{} json.NewDecoder(resp.Body).Decode(&prob) // RFC 9457 required fields if prob["type"] == nil { t.Errorf("expected 'type' field in problem detail") } if prob["title"] == nil { t.Errorf("expected 'title' field in problem detail") } if prob["status"] == nil { t.Errorf("expected 'status' field in problem detail") } if prob["detail"] == nil { t.Errorf("expected 'detail' field in problem detail") } }