package llm import ( "context" "errors" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "strconv" "strings" "sync" "testing" "time" "gitea.maximumdirect.net/eric/promptkit/internal/domain" ) func TestGetWithEvidenceCapturesResponseAndOmitsURLUserInfo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { if request.Header.Get("Authorization") == "" { t.Error("request did not carry URL user credentials") } w.WriteHeader(http.StatusNoContent) })) defer server.Close() client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{Timeout: time.Second}) if err != nil { t.Fatalf("construct client: %v", err) } endpoint := strings.Replace(server.URL, "http://", "http://user:secret@", 1) + "/health" response, evidence, err := client.GetWithEvidence(context.Background(), endpoint, 0) if err != nil { t.Fatalf("GET endpoint: %v", err) } if response == nil { t.Fatal("GET returned no response") } response.Body.Close() if !evidence.DispatchInvoked || !evidence.RequestObserved || !evidence.ResponseReceived { t.Fatalf("incomplete response evidence: %+v", evidence) } if evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusNoContent { t.Fatalf("status evidence = %v, want %d", evidence.HTTPStatus, http.StatusNoContent) } if !evidence.CredentialsSupplied { t.Fatal("evidence did not record URL credentials") } if strings.Contains(evidence.Endpoint, "user") || strings.Contains(evidence.Endpoint, "secret") { t.Fatalf("exposed endpoint contains URL user info: %q", evidence.Endpoint) } if want := server.URL + "/health"; evidence.Endpoint != want { t.Fatalf("endpoint = %q, want %q", evidence.Endpoint, want) } } func TestGetWithAuthenticationEvidenceResolvesOptionalCredentials(t *testing.T) { tests := []struct { name string target domain.ExecutionTarget environment string wantAuth string }{ { name: "direct key takes precedence", target: domain.ExecutionTarget{ APIKey: " direct-key ", APIKeyEnv: "PROMPTKIT_DIAGNOSTIC_KEY", }, environment: "environment-key", wantAuth: "Bearer direct-key", }, { name: "environment key is used", target: domain.ExecutionTarget{APIKeyEnv: "PROMPTKIT_DIAGNOSTIC_KEY"}, environment: " environment-key ", wantAuth: "Bearer environment-key", }, { name: "missing required key is optional here", target: domain.ExecutionTarget{APIKeyEnv: "PROMPTKIT_DIAGNOSTIC_KEY", APIKeyRequired: true}, environment: " ", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Setenv("PROMPTKIT_DIAGNOSTIC_KEY", tc.environment) var authorization string client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{ HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { authorization = request.Header.Get("Authorization") return &http.Response{ StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("")), }, nil })}, }) if err != nil { t.Fatal(err) } response, evidence, err := client.GetWithAuthenticationEvidence( context.Background(), "https://example.invalid/key", 0, tc.target, ) if err != nil || response == nil { t.Fatalf("response=%v error=%v", response, err) } response.Body.Close() if authorization != tc.wantAuth || evidence.CredentialsSupplied != (tc.wantAuth != "") { t.Fatalf("authorization=%q evidence=%+v", authorization, evidence) } if evidence.Endpoint != "https://example.invalid/key" || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusUnauthorized { t.Fatalf("HTTP evidence = %+v", evidence) } }) } } func TestDoWithEvidenceRecordsRequestWithoutResponse(t *testing.T) { transportErr := errors.New("transport unavailable") client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, transportErr })} request, err := http.NewRequest(http.MethodGet, "https://diagnostic.example/status", nil) if err != nil { t.Fatalf("create request: %v", err) } response, err, evidence := doWithEvidence(client, request) if response != nil || !errors.Is(err, transportErr) { t.Fatalf("response/error = %v/%v, want nil/transport error", response, err) } if !evidence.DispatchInvoked || !evidence.RequestObserved || evidence.ResponseReceived { t.Fatalf("unexpected request evidence: %+v", evidence) } if evidence.HTTPStatus != nil || evidence.Endpoint != request.URL.String() || evidence.CredentialsSupplied { t.Fatalf("unexpected evidence values: %+v", evidence) } } func TestDoWithEvidenceRetainsResponseReturnedAlongsideError(t *testing.T) { transportErr := errors.New("transport reported an error with a response") client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { return responseFor(request, http.StatusBadGateway, ""), transportErr })} request, err := http.NewRequest(http.MethodGet, "https://diagnostic.example/status", nil) if err != nil { t.Fatalf("create request: %v", err) } response, err, evidence := doWithEvidence(client, request) if !errors.Is(err, transportErr) { t.Fatalf("error = %v, want transport error", err) } if response != nil { response.Body.Close() } if !evidence.RequestObserved || !evidence.ResponseReceived || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusBadGateway { t.Fatalf("observed response was lost alongside error: %+v", evidence) } } func TestDoWithEvidenceUsesLastRedirectRequest(t *testing.T) { var observedAuthorization []string transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { observedAuthorization = append(observedAuthorization, request.Header.Get("Authorization")) if request.URL.Host == "source.example" { return responseFor(request, http.StatusFound, "https://other.example/final"), nil } return responseFor(request, http.StatusUnauthorized, ""), nil }) client := &http.Client{Transport: transport} request, err := http.NewRequest(http.MethodGet, "https://source.example/start", nil) if err != nil { t.Fatalf("create request: %v", err) } request.Header.Set("Authorization", "Bearer private-token") response, err, evidence := doWithEvidence(client, request) if err != nil { t.Fatalf("follow redirect: %v", err) } if response == nil { t.Fatal("redirect returned no response") } response.Body.Close() if len(observedAuthorization) != 2 || observedAuthorization[0] == "" || observedAuthorization[1] != "" { t.Fatalf("authorization across redirect = %#v", observedAuthorization) } if evidence.Endpoint != "https://other.example/final" || evidence.CredentialsSupplied { t.Fatalf("evidence did not describe credential-stripped last hop: %+v", evidence) } if !evidence.ResponseReceived || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusUnauthorized { t.Fatalf("last-hop response evidence = %+v", evidence) } } func TestDoWithEvidenceClearsPriorResponseOnFailedRedirect(t *testing.T) { transportErr := errors.New("redirect target unavailable") transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { if request.URL.Host == "source.example" { return responseFor(request, http.StatusTemporaryRedirect, "https://target.example/unavailable"), nil } return nil, transportErr }) client := &http.Client{Transport: transport} request, err := http.NewRequest(http.MethodGet, "https://source.example/start", nil) if err != nil { t.Fatalf("create request: %v", err) } response, err, evidence := doWithEvidence(client, request) if response != nil || !errors.Is(err, transportErr) { t.Fatalf("response/error = %v/%v, want nil/transport error", response, err) } if !evidence.RequestObserved || evidence.Endpoint != "https://target.example/unavailable" { t.Fatalf("evidence did not retain failed last request: %+v", evidence) } if evidence.ResponseReceived || evidence.HTTPStatus != nil { t.Fatalf("prior redirect response was not cleared: %+v", evidence) } } func TestDoWithEvidenceRetainsResponseWhenRedirectPolicyStops(t *testing.T) { redirectErr := errors.New("redirect rejected") transportCalls := 0 client := &http.Client{ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { transportCalls++ return responseFor(request, http.StatusFound, "https://other.example/never-requested"), nil }), CheckRedirect: func(*http.Request, []*http.Request) error { return redirectErr }, } request, err := http.NewRequest(http.MethodGet, "https://source.example/start", nil) if err != nil { t.Fatalf("create request: %v", err) } response, err, evidence := doWithEvidence(client, request) if !errors.Is(err, redirectErr) { t.Fatalf("redirect error = %v, want policy error", err) } if response == nil || response.StatusCode != http.StatusFound { t.Fatalf("redirect response = %v, want original response", response) } response.Body.Close() if transportCalls != 1 || evidence.Endpoint != "https://source.example/start" { t.Fatalf("redirect policy inferred or dispatched another request: calls=%d evidence=%+v", transportCalls, evidence) } if !evidence.ResponseReceived || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusFound { t.Fatalf("original response evidence was lost: %+v", evidence) } } func TestGetWithEvidencePreservesClientAndIsolatesConcurrentCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer server.Close() redirect := func(*http.Request, []*http.Request) error { return nil } jar, err := cookiejar.New(nil) if err != nil { t.Fatalf("construct cookie jar: %v", err) } transport := http.DefaultTransport supplied := &http.Client{Transport: transport, Timeout: 7 * time.Second, CheckRedirect: redirect, Jar: jar} client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{HTTPClient: supplied}) if err != nil { t.Fatalf("construct client: %v", err) } const calls = 8 evidences := make([]HTTPExecutionEvidence, calls) errs := make([]error, calls) var wait sync.WaitGroup for index := 0; index < calls; index++ { wait.Add(1) go func(index int) { defer wait.Done() endpoint := server.URL + "/call/" + strconv.Itoa(index) response, evidence, getErr := client.GetWithEvidence(context.Background(), endpoint, 0) if response != nil { response.Body.Close() } evidences[index], errs[index] = evidence, getErr }(index) } wait.Wait() if supplied.Transport != transport || supplied.Timeout != 7*time.Second || supplied.CheckRedirect == nil || supplied.Jar != jar { t.Fatal("diagnostic GET mutated the supplied HTTP client") } for index, evidence := range evidences { if errs[index] != nil { t.Fatalf("GET %d: %v", index, errs[index]) } want := server.URL + "/call/" + strconv.Itoa(index) if !evidence.RequestObserved || evidence.Endpoint != want { t.Fatalf("GET %d evidence = %+v, want endpoint %q", index, evidence, want) } } } func TestGetWithEvidenceAppliesTargetTimeout(t *testing.T) { client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{Timeout: 5 * time.Second, HTTPClient: &http.Client{ Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { <-request.Context().Done() return nil, request.Context().Err() }), }}) if err != nil { t.Fatalf("construct client: %v", err) } response, evidence, err := client.GetWithEvidence(context.Background(), "https://diagnostic.example/slow", 1) if response != nil || !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("response/error = %v/%v, want nil/deadline exceeded", response, err) } if !evidence.DispatchInvoked || !evidence.RequestObserved || evidence.ResponseReceived { t.Fatalf("timeout evidence = %+v", evidence) } } func responseFor(request *http.Request, status int, location string) *http.Response { header := make(http.Header) if location != "" { header.Set("Location", location) } return &http.Response{ StatusCode: status, Header: header, Body: io.NopCloser(strings.NewReader("")), Request: request, } }