578 lines
22 KiB
Go
578 lines
22 KiB
Go
//go:build integration
|
|
|
|
// Package integration contains Sprint 11 E2E tests (E11-01a).
|
|
// Batch 1 covers: auth flows, proxy chat, streaming SSE, audit logging,
|
|
// rate limiting, and feature flags (pii_enabled, billing_enabled).
|
|
//
|
|
// Run with: go test -tags integration -v ./test/integration/ -run TestE2E_
|
|
// All tests use httptest.Server + in-memory stubs — no external services needed.
|
|
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/veylant/ia-gateway/internal/apierror"
|
|
"github.com/veylant/ia-gateway/internal/auditlog"
|
|
"github.com/veylant/ia-gateway/internal/config"
|
|
"github.com/veylant/ia-gateway/internal/flags"
|
|
"github.com/veylant/ia-gateway/internal/health"
|
|
"github.com/veylant/ia-gateway/internal/middleware"
|
|
"github.com/veylant/ia-gateway/internal/provider"
|
|
"github.com/veylant/ia-gateway/internal/proxy"
|
|
"github.com/veylant/ia-gateway/internal/ratelimit"
|
|
"github.com/veylant/ia-gateway/internal/router"
|
|
)
|
|
|
|
// ─── Test constants ──────────────────────────────────────────────────────────
|
|
|
|
const (
|
|
e2eTenantID = "00000000-0000-0000-0000-000000000042"
|
|
e2eUserID = "user-e2e-test"
|
|
)
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
// e2eAdminClaims returns authenticated admin claims for the E2E tenant.
|
|
func e2eAdminClaims() *middleware.UserClaims {
|
|
return &middleware.UserClaims{
|
|
UserID: e2eUserID,
|
|
TenantID: e2eTenantID,
|
|
Email: "e2e-admin@veylant.test",
|
|
Roles: []string{"admin"},
|
|
}
|
|
}
|
|
|
|
// e2eUserClaims returns authenticated user-role claims for the E2E tenant.
|
|
func e2eUserClaims(roles ...string) *middleware.UserClaims {
|
|
if len(roles) == 0 {
|
|
roles = []string{"user"}
|
|
}
|
|
return &middleware.UserClaims{
|
|
UserID: e2eUserID,
|
|
TenantID: e2eTenantID,
|
|
Email: "e2e-user@veylant.test",
|
|
Roles: roles,
|
|
}
|
|
}
|
|
|
|
// proxyServerOptions holds optional components for building a proxy test server.
|
|
type proxyServerOptions struct {
|
|
adapter provider.Adapter
|
|
flagStore flags.FlagStore
|
|
auditLog auditlog.Logger
|
|
verifier middleware.TokenVerifier
|
|
// rateLimiter, if nil, a permissive default is used.
|
|
rateLimiter *ratelimit.Limiter
|
|
}
|
|
|
|
// buildProxyServer creates a fully wired httptest.Server with chi router,
|
|
// auth middleware, rate limiter, and proxy handler. All components are
|
|
// in-memory; no external services are required.
|
|
func buildProxyServer(t *testing.T, opts proxyServerOptions) *httptest.Server {
|
|
t.Helper()
|
|
|
|
if opts.adapter == nil {
|
|
opts.adapter = &e2eStubAdapter{
|
|
resp: &provider.ChatResponse{
|
|
ID: "chatcmpl-e2e",
|
|
Model: "gpt-4o",
|
|
Choices: []provider.Choice{{Index: 0, Message: provider.Message{Role: "assistant", Content: "Hello!"}}},
|
|
Usage: provider.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15},
|
|
},
|
|
}
|
|
}
|
|
if opts.flagStore == nil {
|
|
opts.flagStore = flags.NewMemFlagStore()
|
|
}
|
|
if opts.verifier == nil {
|
|
opts.verifier = &middleware.MockVerifier{Claims: e2eAdminClaims()}
|
|
}
|
|
if opts.rateLimiter == nil {
|
|
opts.rateLimiter = ratelimit.New(ratelimit.RateLimitConfig{
|
|
RequestsPerMin: 6000,
|
|
BurstSize: 1000,
|
|
UserRPM: 6000,
|
|
UserBurst: 1000,
|
|
IsEnabled: true,
|
|
}, zap.NewNop())
|
|
}
|
|
|
|
// Build a single-adapter router (no RBAC enforcement by default — admin role).
|
|
rbac := &config.RBACConfig{
|
|
UserAllowedModels: []string{"gpt-4o", "gpt-4o-mini", "mistral-small"},
|
|
AuditorCanComplete: false,
|
|
}
|
|
providerRouter := router.New(
|
|
map[string]provider.Adapter{"openai": opts.adapter},
|
|
rbac,
|
|
zap.NewNop(),
|
|
)
|
|
providerRouter.WithFlagStore(opts.flagStore)
|
|
|
|
proxyHandler := proxy.NewWithAudit(providerRouter, zap.NewNop(), nil, opts.auditLog, nil).
|
|
WithFlagStore(opts.flagStore)
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(chimiddleware.Recoverer)
|
|
r.Use(middleware.RequestID)
|
|
r.Get("/healthz", health.Handler)
|
|
|
|
r.Route("/v1", func(r chi.Router) {
|
|
r.Use(middleware.Auth(opts.verifier))
|
|
r.Use(middleware.RateLimit(opts.rateLimiter))
|
|
r.Post("/chat/completions", proxyHandler.ServeHTTP)
|
|
})
|
|
|
|
srv := httptest.NewServer(r)
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// doJSON sends a JSON request to the test server and returns the response.
|
|
func doJSON(t *testing.T, client *http.Client, method, url, token string, body interface{}) *http.Response {
|
|
t.Helper()
|
|
var r io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
require.NoError(t, err)
|
|
r = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(context.Background(), method, url, r)
|
|
require.NoError(t, err)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
return resp
|
|
}
|
|
|
|
// chatBody returns a minimal /v1/chat/completions request body.
|
|
func chatBody(model string, stream bool) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"model": model,
|
|
"messages": []map[string]string{{"role": "user", "content": "Dis bonjour."}},
|
|
"stream": stream,
|
|
}
|
|
}
|
|
|
|
// ─── E2E stub adapter ─────────────────────────────────────────────────────────
|
|
|
|
// e2eStubAdapter is a minimal provider.Adapter for E2E tests.
|
|
type e2eStubAdapter struct {
|
|
resp *provider.ChatResponse
|
|
sendErr error
|
|
sseLines []string
|
|
}
|
|
|
|
func (s *e2eStubAdapter) Validate(req *provider.ChatRequest) error {
|
|
if req.Model == "" {
|
|
return apierror.NewBadRequestError("model required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *e2eStubAdapter) Send(_ context.Context, _ *provider.ChatRequest) (*provider.ChatResponse, error) {
|
|
if s.sendErr != nil {
|
|
return nil, s.sendErr
|
|
}
|
|
resp := *s.resp // copy
|
|
return &resp, nil
|
|
}
|
|
|
|
func (s *e2eStubAdapter) Stream(_ context.Context, _ *provider.ChatRequest, w http.ResponseWriter) error {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
return fmt.Errorf("not flushable")
|
|
}
|
|
lines := s.sseLines
|
|
if len(lines) == 0 {
|
|
lines = []string{
|
|
`data: {"id":"chatcmpl-e2e","object":"chat.completion.chunk","choices":[{"delta":{"content":"Hello"}}]}`,
|
|
`data: [DONE]`,
|
|
}
|
|
}
|
|
for _, line := range lines {
|
|
fmt.Fprintf(w, "%s\n\n", line) //nolint:errcheck
|
|
flusher.Flush()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *e2eStubAdapter) HealthCheck(_ context.Context) error { return nil }
|
|
|
|
// ─── Scenario 1: GET /healthz ─────────────────────────────────────────────────
|
|
|
|
// TestE2E_HealthCheck_Returns200 verifies the health endpoint is reachable and
|
|
// returns status "ok". This is the first check in any deployment pipeline.
|
|
func TestE2E_HealthCheck_Returns200(t *testing.T) {
|
|
srv := buildProxyServer(t, proxyServerOptions{})
|
|
resp := doJSON(t, srv.Client(), http.MethodGet, srv.URL+"/healthz", "", nil)
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var body struct {
|
|
Status string `json:"status"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
|
assert.Equal(t, "ok", body.Status)
|
|
}
|
|
|
|
// ─── Scenario 2: Auth — no token → 401 ───────────────────────────────────────
|
|
|
|
// TestE2E_Auth_NoToken_Returns401 verifies that unauthenticated requests are
|
|
// rejected with 401 before they reach the proxy handler.
|
|
func TestE2E_Auth_NoToken_Returns401(t *testing.T) {
|
|
srv := buildProxyServer(t, proxyServerOptions{})
|
|
resp := doJSON(t, srv.Client(), http.MethodPost, srv.URL+"/v1/chat/completions", "", chatBody("gpt-4o", false))
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
|
|
var body struct {
|
|
Error struct{ Type string } `json:"error"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
|
assert.Equal(t, "authentication_error", body.Error.Type)
|
|
}
|
|
|
|
// ─── Scenario 3: Auth — invalid token → 401 ──────────────────────────────────
|
|
|
|
// TestE2E_Auth_InvalidToken_Returns401 verifies that a structurally invalid token
|
|
// causes the OIDC verifier to return an error and the middleware to reply 401.
|
|
func TestE2E_Auth_InvalidToken_Returns401(t *testing.T) {
|
|
srv := buildProxyServer(t, proxyServerOptions{
|
|
verifier: &middleware.MockVerifier{Err: fmt.Errorf("token expired")},
|
|
})
|
|
resp := doJSON(t, srv.Client(), http.MethodPost, srv.URL+"/v1/chat/completions", "invalid-token", chatBody("gpt-4o", false))
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
}
|
|
|
|
// ─── Scenario 4: Auth — valid JWT → proxy responds 200 ───────────────────────
|
|
|
|
// TestE2E_Auth_ValidToken_ProxyResponds200 verifies end-to-end: valid JWT is
|
|
// accepted, proxy dispatches to the stub adapter, and returns a valid response.
|
|
func TestE2E_Auth_ValidToken_ProxyResponds200(t *testing.T) {
|
|
srv := buildProxyServer(t, proxyServerOptions{})
|
|
resp := doJSON(t, srv.Client(), http.MethodPost, srv.URL+"/v1/chat/completions", "valid-token", chatBody("gpt-4o", false))
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
|
|
}
|
|
|
|
// ─── Scenario 5: Basic chat — response has choices ───────────────────────────
|
|
|
|
// TestE2E_Proxy_BasicChat_HasChoices verifies that the LLM response is properly
|
|
// forwarded: the `choices` array is present and contains the stub response.
|
|
func TestE2E_Proxy_BasicChat_HasChoices(t *testing.T) {
|
|
srv := buildProxyServer(t, proxyServerOptions{})
|
|
resp := doJSON(t, srv.Client(), http.MethodPost, srv.URL+"/v1/chat/completions", "valid-token", chatBody("gpt-4o", false))
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var body struct {
|
|
Choices []struct {
|
|
Message struct{ Content string } `json:"message"`
|
|
} `json:"choices"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
|
require.Len(t, body.Choices, 1)
|
|
assert.Equal(t, "Hello!", body.Choices[0].Message.Content)
|
|
}
|
|
|
|
// ─── Scenario 6: Streaming SSE ───────────────────────────────────────────────
|
|
|
|
// TestE2E_Proxy_Streaming_SSEFormat verifies that a stream:true request receives
|
|
// Content-Type: text/event-stream and at least one SSE data chunk.
|
|
func TestE2E_Proxy_Streaming_SSEFormat(t *testing.T) {
|
|
srv := buildProxyServer(t, proxyServerOptions{})
|
|
|
|
body, err := json.Marshal(chatBody("gpt-4o", true))
|
|
require.NoError(t, err)
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
|
|
srv.URL+"/v1/chat/completions", bytes.NewReader(body))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Authorization", "Bearer valid-token")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := srv.Client().Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
|
|
|
|
buf, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(buf), "data:")
|
|
}
|
|
|
|
// ─── Scenario 7: Audit log entry created ─────────────────────────────────────
|
|
|
|
// TestE2E_Proxy_AuditLog_EntryCreated verifies that after a successful request,
|
|
// a single audit entry is recorded with the correct tenant and user fields.
|
|
func TestE2E_Proxy_AuditLog_EntryCreated(t *testing.T) {
|
|
memLog := auditlog.NewMemLogger()
|
|
|
|
srv := buildProxyServer(t, proxyServerOptions{auditLog: memLog})
|
|
resp := doJSON(t, srv.Client(), http.MethodPost, srv.URL+"/v1/chat/completions", "valid-token", chatBody("gpt-4o", false))
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// The audit logger is async but MemLogger.Log is synchronous.
|
|
entries := memLog.Entries()
|
|
require.Len(t, entries, 1)
|
|
|
|
entry := entries[0]
|
|
assert.Equal(t, e2eTenantID, entry.TenantID)
|
|
assert.Equal(t, e2eUserID, entry.UserID)
|
|
assert.Equal(t, "gpt-4o", entry.ModelRequested)
|
|
assert.Equal(t, "ok", entry.Status)
|
|
assert.NotEmpty(t, entry.PromptHash, "prompt hash should be recorded")
|
|
}
|
|
|
|
// ─── Scenario 8: Rate limiting → 429 ─────────────────────────────────────────
|
|
|
|
// TestE2E_Proxy_RateLimit_Returns429 verifies that a tenant with a burst of 1
|
|
// receives 429 on the second request within the same burst window.
|
|
func TestE2E_Proxy_RateLimit_Returns429(t *testing.T) {
|
|
tightLimiter := ratelimit.New(ratelimit.RateLimitConfig{
|
|
RequestsPerMin: 60,
|
|
BurstSize: 1, // burst of 1 → second request is rate-limited
|
|
UserRPM: 600,
|
|
UserBurst: 100,
|
|
IsEnabled: true,
|
|
}, zap.NewNop())
|
|
|
|
srv := buildProxyServer(t, proxyServerOptions{rateLimiter: tightLimiter})
|
|
|
|
client := srv.Client()
|
|
|
|
// First request: should succeed.
|
|
resp1 := doJSON(t, client, http.MethodPost, srv.URL+"/v1/chat/completions", "valid-token", chatBody("gpt-4o", false))
|
|
defer resp1.Body.Close() //nolint:errcheck
|
|
assert.Equal(t, http.StatusOK, resp1.StatusCode, "first request should be allowed")
|
|
|
|
// Second request: burst exhausted → 429.
|
|
resp2 := doJSON(t, client, http.MethodPost, srv.URL+"/v1/chat/completions", "valid-token", chatBody("gpt-4o", false))
|
|
defer resp2.Body.Close() //nolint:errcheck
|
|
assert.Equal(t, http.StatusTooManyRequests, resp2.StatusCode, "second request should be rate limited")
|
|
|
|
var body struct {
|
|
Error struct{ Type string } `json:"error"`
|
|
}
|
|
_ = json.NewDecoder(resp2.Body).Decode(&body)
|
|
assert.Equal(t, "rate_limit_error", body.Error.Type)
|
|
}
|
|
|
|
// ─── Scenario 9: billing_enabled flag → CostUSD zeroed ───────────────────────
|
|
|
|
// TestE2E_Proxy_Billing_Flag_Disabled_ZerosCost verifies that when billing_enabled
|
|
// is set to false for a tenant, the CostUSD field in the audit entry is zeroed.
|
|
func TestE2E_Proxy_Billing_Flag_Disabled_ZerosCost(t *testing.T) {
|
|
memLog := auditlog.NewMemLogger()
|
|
fs := flags.NewMemFlagStore()
|
|
|
|
// Explicitly disable billing for the test tenant.
|
|
ctx := context.Background()
|
|
_, err := fs.Set(ctx, e2eTenantID, "billing_enabled", false)
|
|
require.NoError(t, err)
|
|
// Also seed pii_enabled=true so the flag store is "warm" (like after migration 000009).
|
|
_, err = fs.Set(ctx, "", "pii_enabled", true)
|
|
require.NoError(t, err)
|
|
|
|
srv := buildProxyServer(t, proxyServerOptions{auditLog: memLog, flagStore: fs})
|
|
resp := doJSON(t, srv.Client(), http.MethodPost, srv.URL+"/v1/chat/completions", "valid-token", chatBody("gpt-4o", false))
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
entries := memLog.Entries()
|
|
require.Len(t, entries, 1)
|
|
assert.Equal(t, 0.0, entries[0].CostUSD, "billing_enabled=false should zero the cost")
|
|
}
|
|
|
|
// ─── Scenario 10: RBAC — user role + allowed model → 200 ─────────────────────
|
|
|
|
// TestE2E_Auth_RBAC_UserRole_AllowedModel_Returns200 verifies that a `user`-role
|
|
// JWT can call /v1/chat/completions for models in the allowlist (gpt-4o-mini).
|
|
func TestE2E_Auth_RBAC_UserRole_AllowedModel_Returns200(t *testing.T) {
|
|
// User-role verifier (not admin).
|
|
userVerifier := &middleware.MockVerifier{Claims: e2eUserClaims("user")}
|
|
|
|
// Adapter that responds to gpt-4o-mini requests.
|
|
adapter := &e2eStubAdapter{
|
|
resp: &provider.ChatResponse{
|
|
ID: "chatcmpl-mini",
|
|
Model: "gpt-4o-mini",
|
|
Choices: []provider.Choice{{Message: provider.Message{Role: "assistant", Content: "Hi!"}}},
|
|
Usage: provider.Usage{TotalTokens: 5},
|
|
},
|
|
}
|
|
|
|
srv := buildProxyServer(t, proxyServerOptions{
|
|
adapter: adapter,
|
|
verifier: userVerifier,
|
|
})
|
|
|
|
// gpt-4o-mini is in the default UserAllowedModels — should be allowed.
|
|
resp := doJSON(t, srv.Client(), http.MethodPost, srv.URL+"/v1/chat/completions", "user-token", chatBody("gpt-4o-mini", false))
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode,
|
|
"user role should be allowed to use gpt-4o-mini (in allowlist)")
|
|
}
|
|
|
|
// ─── Additional: request ID propagation ──────────────────────────────────────
|
|
|
|
// TestE2E_Proxy_RequestID_InLogs verifies that the X-Request-Id header is
|
|
// set on the request context and the request ID is stored in the audit entry.
|
|
func TestE2E_Proxy_RequestID_InLogs(t *testing.T) {
|
|
memLog := auditlog.NewMemLogger()
|
|
srv := buildProxyServer(t, proxyServerOptions{auditLog: memLog})
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
|
|
srv.URL+"/v1/chat/completions",
|
|
strings.NewReader(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Authorization", "Bearer valid-token")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Request-Id", "e2e-test-req-id-42")
|
|
|
|
resp, err := srv.Client().Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
entries := memLog.Entries()
|
|
require.Len(t, entries, 1)
|
|
assert.NotEmpty(t, entries[0].RequestID, "request ID should be recorded in audit entry")
|
|
}
|
|
|
|
// ─── Additional: pii_enabled flag does not break normal flow ─────────────────
|
|
|
|
// TestE2E_Proxy_PII_Flag_Enabled_NoClientIsNoOp verifies that pii_enabled=true
|
|
// with no PII client configured (nil) does not cause an error — the proxy
|
|
// degrades gracefully by skipping PII anonymization.
|
|
func TestE2E_Proxy_PII_Flag_Enabled_NoClientIsNoOp(t *testing.T) {
|
|
fs := flags.NewMemFlagStore()
|
|
ctx := context.Background()
|
|
// Seed pii_enabled=true globally (as migration 000009 does).
|
|
_, err := fs.Set(ctx, "", "pii_enabled", true)
|
|
require.NoError(t, err)
|
|
|
|
memLog := auditlog.NewMemLogger()
|
|
srv := buildProxyServer(t, proxyServerOptions{flagStore: fs, auditLog: memLog})
|
|
|
|
resp := doJSON(t, srv.Client(), http.MethodPost, srv.URL+"/v1/chat/completions", "valid-token",
|
|
map[string]interface{}{
|
|
"model": "gpt-4o",
|
|
"messages": []map[string]string{{"role": "user", "content": "Mon IBAN est FR76 3000 6000 0112 3456 7890 189"}},
|
|
})
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
// pii_enabled=true but no PII client → still succeeds, no error.
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
entries := memLog.Entries()
|
|
require.Len(t, entries, 1)
|
|
// No PII client → entity count is 0.
|
|
assert.Equal(t, 0, entries[0].PIIEntityCount)
|
|
}
|
|
|
|
// ─── Additional: invalid JSON body → 400 ─────────────────────────────────────
|
|
|
|
// TestE2E_Proxy_InvalidJSON_Returns400 verifies that a malformed JSON body
|
|
// is rejected immediately with 400 invalid_request_error.
|
|
func TestE2E_Proxy_InvalidJSON_Returns400(t *testing.T) {
|
|
srv := buildProxyServer(t, proxyServerOptions{})
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
|
|
srv.URL+"/v1/chat/completions",
|
|
strings.NewReader(`{not valid json`))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Authorization", "Bearer valid-token")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := srv.Client().Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
|
|
var body struct {
|
|
Error struct{ Type string } `json:"error"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
|
assert.Equal(t, "invalid_request_error", body.Error.Type)
|
|
}
|
|
|
|
// ─── Additional: upstream error → 502 ────────────────────────────────────────
|
|
|
|
// TestE2E_Proxy_UpstreamError_Returns502 verifies that when the LLM provider
|
|
// returns an error, the proxy responds with 502 and the correct error type.
|
|
func TestE2E_Proxy_UpstreamError_Returns502(t *testing.T) {
|
|
errAdapter := &e2eStubAdapter{
|
|
resp: nil,
|
|
sendErr: fmt.Errorf("connection refused"),
|
|
}
|
|
srv := buildProxyServer(t, proxyServerOptions{adapter: errAdapter})
|
|
|
|
resp := doJSON(t, srv.Client(), http.MethodPost, srv.URL+"/v1/chat/completions", "valid-token", chatBody("gpt-4o", false))
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
assert.Equal(t, http.StatusBadGateway, resp.StatusCode)
|
|
}
|
|
|
|
// ─── Additional: routing_enabled flag (via MemFlagStore seeding) ─────────────
|
|
|
|
// TestE2E_Proxy_RoutingFlag_Enabled_DefaultsBehavior verifies that explicitly
|
|
// setting routing_enabled=true (global default from migration 000009) does not
|
|
// break the static routing path when no routing engine is configured.
|
|
func TestE2E_Proxy_RoutingFlag_Enabled_DefaultsBehavior(t *testing.T) {
|
|
fs := flags.NewMemFlagStore()
|
|
ctx := context.Background()
|
|
_, err := fs.Set(ctx, "", "routing_enabled", true)
|
|
require.NoError(t, err)
|
|
|
|
srv := buildProxyServer(t, proxyServerOptions{flagStore: fs})
|
|
resp := doJSON(t, srv.Client(), http.MethodPost, srv.URL+"/v1/chat/completions", "valid-token", chatBody("gpt-4o", false))
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
// routing_enabled=true + no engine → static prefix rules → should still work.
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
}
|
|
|
|
// ─── Ensure test timeout is reasonable ───────────────────────────────────────
|
|
|
|
// TestE2E_Proxy_BatchRunsUnder30s is a meta-test verifying that all proxy E2E
|
|
// tests complete well within the 10-minute CI budget. It times the package.
|
|
func TestE2E_Proxy_BatchRunsUnder30s(t *testing.T) {
|
|
start := time.Now()
|
|
t.Cleanup(func() {
|
|
elapsed := time.Since(start)
|
|
t.Logf("E2E proxy batch wall time: %v", elapsed)
|
|
assert.Less(t, elapsed, 30*time.Second, "proxy E2E tests should complete in < 30s")
|
|
})
|
|
}
|