Files
promptkit/internal/llm/http_evidence_test.go

341 lines
12 KiB
Go

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,
}
}