//go:build integration // Package integration exercises the llmgateway Traefik plugin against a // real Traefik v3.5 binary loading the plugin via localPlugins, real mock // upstreams, and a real shared Redis, all orchestrated by // integration/docker-compose.yml. It assumes the compose stack is already // up and healthy — the `integration` Makefile target starts it, waits for // traefik1 to answer, runs this package, then tears the stack down. To keep // the stack alive between runs for debugging, use `make integration-keep` // (or `make integration-up` followed directly by // `go test -tags integration ./...` from this directory), not a bare // `docker compose up -d --build`: integration-up first renders // integration/traefik/dynamic.yml from dynamic.yml.tmpl (substituting // INTEGRATION_REAL_BASEURL), and that generated dynamic.yml is // git-ignored — running docker compose directly reuses whatever stale or // absent dynamic.yml happens to be on disk instead of a freshly rendered // one. package integration import ( "bytes" "encoding/json" "io" "mime/multipart" "net/http" "os" "os/exec" "path/filepath" "regexp" "slices" "strings" "testing" "time" ) const ( traefik1URL = "http://localhost:19081" traefik2URL = "http://localhost:19082" aliceKey = "sk-int-alice" // eng group, high limit, inline user bobKey = "sk-int-bob" // limited group (requestsPerMinute: 3), file-sourced user carolKey = "sk-int-carol" // added to users.json mid-run by TestUsersFileHotReload adminKey = "sk-int-admin" // eng group, inline user, admin: true (spec §4, v0.2) // hotReloadProbeKey belongs to a dedicated "hotreload" group // (requestsPerMinute: 100000, dynamic.yml.tmpl) — used only by // TestConfigHotReload's sustained request loop and its /v1/models // polls (config_hot_reload_test.go), isolated from "eng"'s shared, // much lower per-minute bucket so that loop's volume can never // masquerade as a reload-caused failure via a budget-exhaustion 429. hotReloadProbeKey = "sk-int-hotreload" // daveKey belongs to the dedicated "restricted" group (models: // ["gpt-mock"], passthroughPaths: ["v1/chat/completions"], // dynamic.yml.tmpl) — used only by // TestPassthroughModelAndPathRestriction (security review finding // 2/3, round 3, 2026-08-22 report correction) to exercise the // native-passthrough model-peek and path-allowlist enforcement under // real Yaegi interpretation; every other group/user in this suite // leaves both fields unset. daveKey = "sk-int-dave" ) // composeFile returns the path to docker-compose.yml relative to this // package's directory, which is also `go test`'s working directory. func composeFile() string { return "docker-compose.yml" } // doJSON issues one JSON request, returning the raw *http.Response // (headers and status code still valid — its body has already been read // and closed) alongside the decoded JSON body. A non-JSON or empty body // decodes to a nil map rather than failing the test; callers that require // a body assert on it themselves. func doJSON(t *testing.T, method, url, apiKey string, body any) (*http.Response, map[string]any) { t.Helper() var reader io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { t.Fatalf("marshal request body: %v", err) } reader = bytes.NewReader(b) } req, err := http.NewRequest(method, url, reader) if err != nil { t.Fatalf("new request %s %s: %v", method, url, err) } if apiKey != "" { req.Header.Set("Authorization", "Bearer "+apiKey) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do request %s %s: %v", method, url, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read response body from %s %s: %v", method, url, err) } var out map[string]any if len(raw) > 0 { _ = json.Unmarshal(raw, &out) // non-JSON body (e.g. an SSE stream) just yields a nil map } return resp, out } // buildMultipartAudio builds a multipart/form-data body carrying a dummy // "file" part and a "model" part set to model, mirroring what an // OpenAI-SDK client sends to POST /v1/audio/transcriptions. It returns the // encoded body and its matching Content-Type (including the boundary // parameter multipart.Writer generated). func buildMultipartAudio(t *testing.T, model string) ([]byte, string) { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, err := mw.CreateFormFile("file", "audio.wav") if err != nil { t.Fatalf("create form file part: %v", err) } if _, err := fw.Write([]byte("mock-audio-bytes")); err != nil { t.Fatalf("write file part: %v", err) } if err := mw.WriteField("model", model); err != nil { t.Fatalf("write model field: %v", err) } if err := mw.Close(); err != nil { t.Fatalf("close multipart writer: %v", err) } return buf.Bytes(), mw.FormDataContentType() } // doMultipart issues one multipart/form-data POST, mirroring doJSON's // contract: the returned *http.Response has already had its body read and // closed, and a non-JSON or empty body decodes to a nil map. func doMultipart(t *testing.T, url, apiKey, contentType string, body []byte) (*http.Response, map[string]any) { t.Helper() req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err != nil { t.Fatalf("new request POST %s: %v", url, err) } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", contentType) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do request POST %s: %v", url, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read response body from POST %s: %v", url, err) } var out map[string]any if len(raw) > 0 { _ = json.Unmarshal(raw, &out) } return resp, out } // firstChoiceContent extracts choices[0].message.content from an // OpenAI-shaped chat completion response body, failing the test if the // shape doesn't match. func firstChoiceContent(t *testing.T, body map[string]any) string { t.Helper() choices, ok := body["choices"].([]any) if !ok || len(choices) == 0 { t.Fatalf("response has no choices: %#v", body) } choice, ok := choices[0].(map[string]any) if !ok { t.Fatalf("choices[0] is not an object: %#v", choices[0]) } message, ok := choice["message"].(map[string]any) if !ok { t.Fatalf("choices[0].message is not an object: %#v", choice) } content, _ := message["content"].(string) return content } // waitForHealthy polls url with apiKey until it answers 200 or timeout // elapses. func waitForHealthy(t *testing.T, url, apiKey string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) var lastErr error var lastStatus int for time.Now().Before(deadline) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Authorization", "Bearer "+apiKey) resp, err := http.DefaultClient.Do(req) if err != nil { lastErr = err time.Sleep(time.Second) continue } _ = resp.Body.Close() lastStatus = resp.StatusCode if resp.StatusCode == http.StatusOK { return } time.Sleep(time.Second) } t.Fatalf("%s did not become healthy within %s (last status=%d, last error=%v)", url, timeout, lastStatus, lastErr) } // waitForModelAlias polls GET /v1/models on baseURL with apiKey until an // entry with id wantID appears, or timeout elapses. Task 6 review fix: a // plain 200 from /v1/models (waitForHealthy's own check) only proves // Traefik's file provider has loaded SOME generation of dynamic.yml — not // necessarily the one carrying modelAliases. A request landing in that // narrow propagation window would see a config generation from before the // alias applied and get a spurious "unknown model" 404, which is exactly // what one cold-start run of this suite hit. Polling the actual listing // removes that window deterministically instead of guessing at a fixed // sleep. func waitForModelAlias(t *testing.T, baseURL, apiKey, wantID string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) var lastBody map[string]any for time.Now().Before(deadline) { resp, body := doJSON(t, http.MethodGet, baseURL+"/v1/models", apiKey, nil) if resp.StatusCode == http.StatusOK { lastBody = body if data, ok := body["data"].([]any); ok { for _, entry := range data { if m, ok := entry.(map[string]any); ok && m["id"] == wantID { return } } } } time.Sleep(500 * time.Millisecond) } t.Fatalf("GET %s/v1/models never listed alias %q within %s, last body=%#v", baseURL, wantID, timeout, lastBody) } // checkNoPluginErrors greps docker compose's captured logs for service, // failing if no plugin-loading message appears at all, or if any // plugin-related line is logged at error level. func checkNoPluginErrors(t *testing.T, service string) { t.Helper() out, err := exec.Command("docker", "compose", "-f", composeFile(), "logs", service).CombinedOutput() if err != nil { t.Fatalf("docker compose logs %s: %v\n%s", service, err, out) } logs := string(out) if !strings.Contains(strings.ToLower(logs), "plugin") { t.Fatalf("%s logs contain no plugin-related message at all (plugin may not have loaded):\n%s", service, logs) } for _, line := range strings.Split(logs, "\n") { ll := strings.ToLower(line) isPluginLine := strings.Contains(ll, "plugin") isErrorLine := strings.Contains(ll, "level=error") || strings.Contains(ll, `"level":"error"`) if isPluginLine && isErrorLine { t.Fatalf("%s logs contain a plugin error line:\n%s", service, line) } } } // TestPluginLoads covers integration test 1: the plugin loads inside a // real Traefik binary with no plugin errors logged, and an authenticated // GET /v1/models returns the models discovery pulled from the mock // upstreams at plugin construction. func TestPluginLoads(t *testing.T) { waitForHealthy(t, traefik1URL+"/v1/models", aliceKey, 120*time.Second) waitForHealthy(t, traefik2URL+"/v1/models", aliceKey, 120*time.Second) checkNoPluginErrors(t, "traefik1") checkNoPluginErrors(t, "traefik2") resp, body := doJSON(t, http.MethodGet, traefik1URL+"/v1/models", aliceKey, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("GET /v1/models: status = %d, want 200, body=%#v", resp.StatusCode, body) } data, ok := body["data"].([]any) if !ok || len(data) == 0 { t.Fatalf("GET /v1/models: expected a non-empty discovered model list, got %#v", body) } } // TestUnifiedChatAllProviders covers integration test 2: a unified chat // completion against each of the three mock upstreams under real Traefik. // openai's response is passed through verbatim (already OpenAI-shaped); // anthropic's and gemini's are translated into the same OpenAI-shaped // envelope, each with the client's requested provider-alias echoed back // in "model" — both translation paths run under real Yaegi here, not just // go test, which is exactly what caught Task 15's yaegi-only bugs in // registry.go, routes_unified.go, sse.go, and translate_gemini.go. func TestUnifiedChatAllProviders(t *testing.T) { openaiReq := map[string]any{ "model": "openai/gpt-mock", "messages": []map[string]any{{"role": "user", "content": "hi"}}, } resp, body := doJSON(t, http.MethodPost, traefik1URL+"/v1/chat/completions", aliceKey, openaiReq) if resp.StatusCode != http.StatusOK { t.Fatalf("openai chat: status = %d, body=%#v", resp.StatusCode, body) } if content := firstChoiceContent(t, body); content != "mock openai response" { t.Errorf("openai chat: content = %q, want %q", content, "mock openai response") } anthropicReq := map[string]any{ "model": "anthropic/claude-mock", "messages": []map[string]any{{"role": "user", "content": "hi"}}, } resp2, body2 := doJSON(t, http.MethodPost, traefik1URL+"/v1/chat/completions", aliceKey, anthropicReq) if resp2.StatusCode != http.StatusOK { t.Fatalf("anthropic chat: status = %d, body=%#v", resp2.StatusCode, body2) } if content := firstChoiceContent(t, body2); content != "mock anthropic response" { t.Errorf("anthropic chat: content = %q, want %q", content, "mock anthropic response") } if model, _ := body2["model"].(string); model != "anthropic/claude-mock" { t.Errorf("anthropic chat: response model = %q, want the client-requested alias %q echoed back", model, "anthropic/claude-mock") } geminiReq := map[string]any{ "model": "gemini/gemini-mock", // max_tokens exercises translate_gemini.go's hasMaxTokens=true // branch (see its comment on the yaegi multi-value-assignment // workaround) under real Traefik + Yaegi, not just go test. "max_tokens": 64, "messages": []map[string]any{{"role": "user", "content": "hi"}}, } resp3, body3 := doJSON(t, http.MethodPost, traefik1URL+"/v1/chat/completions", aliceKey, geminiReq) if resp3.StatusCode != http.StatusOK { t.Fatalf("gemini chat: status = %d, body=%#v", resp3.StatusCode, body3) } if content := firstChoiceContent(t, body3); content != "mock gemini response" { t.Errorf("gemini chat: content = %q, want %q", content, "mock gemini response") } if model, _ := body3["model"].(string); model != "gemini/gemini-mock" { t.Errorf("gemini chat: response model = %q, want the client-requested alias %q echoed back", model, "gemini/gemini-mock") } } // TestModelAliases covers v0.2 integration coverage: task 6 (spec §5). // dynamic.yml.tmpl configures two operator-defined aliases — // "aliased/mock" -> "gpt-mock" (openai-type) and "aliased/claude" -> // "claude-mock" (anthropic-type) — proving alias resolution runs under // real Traefik+Yaegi, not just go test, including the documented // echo-behavior asymmetry between provider types (see // registry_test.go's Gateway-level equivalents for the same assertion // under go test). func TestModelAliases(t *testing.T) { // Review fix: wait for the alias to actually appear in /v1/models // before asserting anything below — see waitForModelAlias's doc // comment for why a plain healthy-200 is not enough on its own. waitForModelAlias(t, traefik1URL, aliceKey, "aliased/mock", 30*time.Second) // openai-type: the upstream response is forwarded verbatim // (provider_openai.go), so its "model" field carries whatever the // mock echoes back — the resolved upstream id "gpt-mock", not the // alias. Correctness here is 200 + real content + the cache header // unified routes always set, not the "model" field (documented v0.1 // passthrough asymmetry, unchanged by aliasing). openaiReq := map[string]any{ "model": "aliased/mock", "messages": []map[string]any{{"role": "user", "content": "hi"}}, } resp, body := doJSON(t, http.MethodPost, traefik1URL+"/v1/chat/completions", aliceKey, openaiReq) if resp.StatusCode != http.StatusOK { t.Fatalf("aliased openai chat: status = %d, body=%#v", resp.StatusCode, body) } if content := firstChoiceContent(t, body); content != "mock openai response" { t.Errorf("aliased openai chat: content = %q, want %q", content, "mock openai response") } if got := resp.Header.Get("X-Llmgw-Cache"); got == "" { t.Error("aliased openai chat: X-Llmgw-Cache header missing, want \"miss\" or \"hit\"") } // anthropic-type: translate_anthropic.go builds its own response // envelope and echoes the client's exact requested id (the alias) // back into "model" — this DOES surface the alias. anthropicReq := map[string]any{ "model": "aliased/claude", "messages": []map[string]any{{"role": "user", "content": "hi"}}, } resp2, body2 := doJSON(t, http.MethodPost, traefik1URL+"/v1/chat/completions", aliceKey, anthropicReq) if resp2.StatusCode != http.StatusOK { t.Fatalf("aliased anthropic chat: status = %d, body=%#v", resp2.StatusCode, body2) } if content := firstChoiceContent(t, body2); content != "mock anthropic response" { t.Errorf("aliased anthropic chat: content = %q, want %q", content, "mock anthropic response") } if model, _ := body2["model"].(string); model != "aliased/claude" { t.Errorf("aliased anthropic chat: response model = %q, want the client-requested alias %q echoed back", model, "aliased/claude") } } // TestStreamingIncrementalityProbe covers integration test 3. // // Content correctness always runs, via assertStreamingContent: a host-side // client reading the full SSE body is valid for CONTENT regardless of how // many TCP segments the response arrived as — only a TIMING claim is // invalidated by output buffering, whether that's the Docker Desktop host // port-proxy (the design spec's Appendix A spike lesson) or, as Task 15 // discovered, Traefik/Yaegi itself. It verifies all 5 of the mock's delta // chunks arrive in order, followed by the terminal "[DONE]" event. // // The true incremental-delivery TIMING assertion — the raw-TCP in-network // probe, requiring ≥3 distinct inter-arrival gaps of 100ms or more — is // gated behind INTEGRATION_STREAMING=1 and skipped otherwise. It is // currently known to fail under real Traefik+Yaegi: a confirmed, external, // still-open upstream bug (traefik/traefik#10269, labeled // "kind/bug/confirmed" by Traefik's own maintainers; see also // traefik/yaegi#1600) means Yaegi cannot detect http.Flusher support on an // http.ResponseWriter crossing the compiled-to-interpreted boundary, so no // code in this plugin can force a real per-chunk flush — see sse.go's // newSSEWriter and task-15-report.md for the full reproduction. Set // INTEGRATION_STREAMING=1 to re-check once that upstream issue is fixed. func TestStreamingIncrementalityProbe(t *testing.T) { assertStreamingContent(t) if os.Getenv("INTEGRATION_STREAMING") != "1" { t.Skipf("INTEGRATION_STREAMING not set to 1; skipping the true incremental-delivery timing assertion — known broken under real Traefik+Yaegi (traefik/traefik#10269), not a plugin defect. Content correctness above still ran and passed. Set INTEGRATION_STREAMING=1 to re-check once upstream fixes it.") } out, err := exec.Command("docker", "compose", "-f", composeFile(), "run", "--rm", "probe").CombinedOutput() t.Logf("probe output:\n%s", out) if err != nil { t.Fatalf("streaming incrementality probe failed: %v", err) } } // assertStreamingContent issues one streaming chat completion against the // mock openai upstream from the host side and verifies its SSE body // carries the mock's 5 delta chunks — "Hel", "lo", " wor", "ld", "!" — in // order, followed by the terminal "[DONE]" event. func assertStreamingContent(t *testing.T) { t.Helper() reqBody := map[string]any{ "model": "openai/gpt-mock", "stream": true, "messages": []map[string]any{{"role": "user", "content": "hi"}}, } b, err := json.Marshal(reqBody) if err != nil { t.Fatalf("marshal request: %v", err) } req, err := http.NewRequest(http.MethodPost, traefik1URL+"/v1/chat/completions", bytes.NewReader(b)) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Authorization", "Bearer "+aliceKey) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("streaming content request: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("streaming content: status = %d", resp.StatusCode) } raw, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read streaming body: %v", err) } body := string(raw) searchFrom := 0 for _, want := range []string{"Hel", "lo", " wor", "ld", "!"} { marker := `"content":"` + want + `"` idx := strings.Index(body[searchFrom:], marker) if idx < 0 { t.Fatalf("streaming content: delta %q not found in order at or after byte %d of body:\n%s", want, searchFrom, body) } searchFrom += idx + len(marker) } if !strings.Contains(body[searchFrom:], "[DONE]") { t.Fatalf("streaming content: [DONE] not found after the final delta in body:\n%s", body) } } // flushRedis clears every key in the shared Redis so a rate-limit test // starts from a known-empty state. Required for idempotence when re-run // against a stack `make integration-keep` left up from a previous run — // without this, a stale counter left over from that earlier run could // make the very first request of this run already report 429. func flushRedis(t *testing.T) { t.Helper() out, err := exec.Command("docker", "compose", "-f", composeFile(), "exec", "-T", "redis", "redis-cli", "FLUSHALL").CombinedOutput() if err != nil { t.Fatalf("flush redis: %v\n%s", err, out) } } // redisKeys returns every key in the shared Redis matching pattern (a // redis-cli KEYS glob), via the same docker compose exec pattern // flushRedis uses above. KEYS is O(n) and blocks Redis briefly — fine // against this suite's tiny keyspace, and used only for a one-off test // assertion, never on a hot path. func redisKeys(t *testing.T, pattern string) []string { t.Helper() out, err := exec.Command("docker", "compose", "-f", composeFile(), "exec", "-T", "redis", "redis-cli", "KEYS", pattern).CombinedOutput() if err != nil { t.Fatalf("redis-cli KEYS %s: %v\n%s", pattern, err, out) } var keys []string for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line != "" { keys = append(keys, line) } } return keys } // TestCrossReplicaLimits covers integration test 4: bob's limited group // (requestsPerMinute: 3) is enforced globally across both Traefik // "replicas" via the Redis they share, regardless of which one a given // request lands on. func TestCrossReplicaLimits(t *testing.T) { flushRedis(t) reqBody := map[string]any{ "model": "openai/gpt-mock", "messages": []map[string]any{{"role": "user", "content": "hi"}}, } targets := []string{traefik1URL, traefik1URL, traefik2URL, traefik2URL} var statuses []int var sawRetryAfter bool for _, base := range targets { resp, body := doJSON(t, http.MethodPost, base+"/v1/chat/completions", bobKey, reqBody) statuses = append(statuses, resp.StatusCode) switch resp.StatusCode { case http.StatusOK: case http.StatusTooManyRequests: if resp.Header.Get("Retry-After") != "" { sawRetryAfter = true } default: t.Fatalf("unexpected status %d for bob's request against %s, body=%#v", resp.StatusCode, base, body) } } var ok200, too429 int for _, s := range statuses { if s == http.StatusOK { ok200++ } if s == http.StatusTooManyRequests { too429++ } } if ok200 != 3 || too429 != 1 { t.Fatalf("cross-replica limit: statuses = %v, want exactly three 200s and one 429 (order-independent)", statuses) } if !sawRetryAfter { t.Fatalf("cross-replica limit: the 429 response carried no Retry-After header") } } // TestCrossReplicaEventsAndOverview covers plan section 5's own // "two-replica" integration bullet (v0.3 dashboard task, F3/F4/F9): a // rate-limit rejection recorded on replica 1 must be readable through // replica 2's own GET /admin/api/events (source "redis" — proving the // shared Redis-backed mirror, not merely replica 1's own local ring, // answers replica 2's read), attributed to replica 1's own replica id; // each replica's own GET /admin/api/overview reports a DIFFERENT replica // id (Q9: os.Hostname() -> $HOSTNAME -> "unknown" — two distinct // containers); and the fleet-wide total.rejectionsPerDay counter (F4, Q6: // "attribute rejections to total/all too") reads identically through // either replica, since both read the SAME shared Redis counter, not a // per-process one. func TestCrossReplicaEventsAndOverview(t *testing.T) { flushRedis(t) overview1Resp, overview1Body := doJSON(t, http.MethodGet, traefik1URL+"/admin/api/overview", adminKey, nil) if overview1Resp.StatusCode != http.StatusOK { t.Fatalf("GET %s/admin/api/overview: status = %d, body=%#v", traefik1URL, overview1Resp.StatusCode, overview1Body) } replica1, _ := overview1Body["replica"].(string) if replica1 == "" { t.Fatalf("GET %s/admin/api/overview: replica = %q, want non-empty", traefik1URL, replica1) } overview2Resp, overview2Body := doJSON(t, http.MethodGet, traefik2URL+"/admin/api/overview", adminKey, nil) if overview2Resp.StatusCode != http.StatusOK { t.Fatalf("GET %s/admin/api/overview: status = %d, body=%#v", traefik2URL, overview2Resp.StatusCode, overview2Body) } replica2, _ := overview2Body["replica"].(string) if replica2 == "" { t.Fatalf("GET %s/admin/api/overview: replica = %q, want non-empty", traefik2URL, replica2) } if replica1 == replica2 { t.Fatalf("GET /admin/api/overview: replica1 = %q, replica2 = %q, want two distinct replica ids (two separate containers)", replica1, replica2) } // Exhaust bob's shared requestsPerMinute:3 bucket, all four requests // against replica 1 ONLY (unlike TestCrossReplicaLimits, above, which // deliberately alternates), so the resulting rate_limit event is // unambiguously attributed to replica1's own id. reqBody := map[string]any{ "model": "openai/gpt-mock", "messages": []map[string]any{{"role": "user", "content": "hi"}}, } var saw429 bool for i := 0; i < 4; i++ { resp, body := doJSON(t, http.MethodPost, traefik1URL+"/v1/chat/completions", bobKey, reqBody) switch resp.StatusCode { case http.StatusOK: case http.StatusTooManyRequests: saw429 = true default: t.Fatalf("unexpected status %d for bob's request %d against replica 1, body=%#v", resp.StatusCode, i, body) } } if !saw429 { t.Fatal("want at least one 429 among 4 requests against bob's requestsPerMinute:3 bucket on replica 1") } // Read GET /admin/api/events THROUGH REPLICA 2 — proving the shared // Redis mirror, not just replica 1's own local ring, carries the // event. eventsResp, eventsBody := doJSON(t, http.MethodGet, traefik2URL+"/admin/api/events?limit=10", adminKey, nil) if eventsResp.StatusCode != http.StatusOK { t.Fatalf("GET %s/admin/api/events: status = %d, body=%#v", traefik2URL, eventsResp.StatusCode, eventsBody) } if source, _ := eventsBody["source"].(string); source != "redis" { t.Fatalf(`GET %s/admin/api/events: source = %q, want "redis" (replica 2 has no local record of an event replica 1 recorded)`, traefik2URL, source) } events, _ := eventsBody["events"].([]any) if len(events) == 0 { t.Fatalf("GET %s/admin/api/events: want at least one event, body=%#v", traefik2URL, eventsBody) } first, ok := events[0].(map[string]any) if !ok { t.Fatalf("GET %s/admin/api/events: events[0] is not an object: %v", traefik2URL, events[0]) } if kind, _ := first["kind"].(string); kind != "rate_limit" { t.Fatalf(`GET %s/admin/api/events: events[0] (newest first).kind = %q, want "rate_limit"`, traefik2URL, kind) } if eventReplica, _ := first["replica"].(string); eventReplica != replica1 { t.Fatalf("GET %s/admin/api/events: events[0].replica = %q, want %q (replica 1, where the rejection actually happened)", traefik2URL, eventReplica, replica1) } // total.rejectionsPerDay (F4) must read identically through either // replica. usage1Resp, usage1Body := doJSON(t, http.MethodGet, traefik1URL+"/admin/api/usage", adminKey, nil) if usage1Resp.StatusCode != http.StatusOK { t.Fatalf("GET %s/admin/api/usage: status = %d, body=%#v", traefik1URL, usage1Resp.StatusCode, usage1Body) } usage2Resp, usage2Body := doJSON(t, http.MethodGet, traefik2URL+"/admin/api/usage", adminKey, nil) if usage2Resp.StatusCode != http.StatusOK { t.Fatalf("GET %s/admin/api/usage: status = %d, body=%#v", traefik2URL, usage2Resp.StatusCode, usage2Body) } total1, _ := usage1Body["total"].(map[string]any) total2, _ := usage2Body["total"].(map[string]any) rej1, _ := total1["rejectionsPerDay"].(float64) rej2, _ := total2["rejectionsPerDay"].(float64) if rej1 < 1 { t.Fatalf("GET %s/admin/api/usage: total.rejectionsPerDay = %v, want >= 1 after a real 429", traefik1URL, total1["rejectionsPerDay"]) } if rej1 != rej2 { t.Fatalf("total.rejectionsPerDay differs across replicas: replica1 = %v, replica2 = %v, want identical (same shared Redis counter)", rej1, rej2) } } // TestUsersFileHotReload covers integration test 5: appending a new user // to the file-sourced users.json (bind-mounted read-only into both // Traefik containers, edited here on the host) makes their key valid // within the plugin's reload window, with no restart. func TestUsersFileHotReload(t *testing.T) { usersPath := filepath.Join("users", "users.json") original, err := os.ReadFile(usersPath) if err != nil { t.Fatalf("read users.json: %v", err) } t.Cleanup(func() { if err := os.WriteFile(usersPath, original, 0o644); err != nil { t.Errorf("restore users.json: %v", err) } }) // carol lands in "eng", not bob's "limited" group: TestCrossReplicaLimits // runs earlier and deliberately exhausts "limited"'s shared per-minute // bucket, which would make polling below flake if carol shared it. updated := `{"users":[` + `{"name":"bob","group":"limited","apiKey":"sk-int-bob"},` + `{"name":"carol","group":"eng","apiKey":"sk-int-carol"}` + `]}` if err := os.WriteFile(usersPath, []byte(updated), 0o644); err != nil { t.Fatalf("write users.json: %v", err) } // 60s, not the plugin's 5s reload throttle alone: this is a real docker // compose stack, not an in-process unit test. On Docker Desktop for // macOS, VirtioFS's host-write-to-guest-read propagation for a // bind-mounted file is NOT bounded by the plugin's own reload logic — // direct measurement (writing users.json on the host and polling the // container's view of it via `docker compose exec`, independent of this // plugin entirely) showed convergence times from under 1s up to ~18s, // and one captured real run of this test took ~26s end to end (traefik1 // logged repeated "unexpected end of JSON input" reload errors — a torn // read of an in-flight VirtioFS content update — every ~5s until the // guest's view finally caught up). That is a VirtioFS-level cache // staleness/coherency artifact of this host/mount combination, not a // plugin bug: users_file.go already re-opens and re-reads the file by // path on every reload (see its own comment), which is the correct, // production-safe pattern for a Kubernetes ConfigMap/Secret atomic // symlink-swap update too. 60s gives >2x margin over the worst // convergence time observed so far. start := time.Now() deadline := start.Add(60 * time.Second) var lastStatus int for time.Now().Before(deadline) { resp, _ := doJSON(t, http.MethodGet, traefik1URL+"/v1/models", carolKey, nil) lastStatus = resp.StatusCode if resp.StatusCode == http.StatusOK { t.Logf("carol's key became valid %s after users.json changed", time.Since(start)) return } time.Sleep(time.Second) } t.Fatalf("carol's key did not become valid within 60s of users.json changing (last status=%d)", lastStatus) } // TestPassthroughAndMCPStream covers integration test 6: native passthrough // forwards a request with the provider's own configured key injected (the // client's gateway key never reaches the upstream), and the MCP target // proxy reverse-proxies an SSE stream through unmodified. func TestPassthroughAndMCPStream(t *testing.T) { reqBody := map[string]any{ "model": "gpt-mock", "messages": []map[string]any{{"role": "user", "content": "hi"}}, } resp, body := doJSON(t, http.MethodPost, traefik1URL+"/openai/v1/chat/completions", aliceKey, reqBody) if resp.StatusCode != http.StatusOK { t.Fatalf("passthrough: status = %d, body=%#v", resp.StatusCode, body) } if got, want := resp.Header.Get("X-Mock-Received-Auth"), "Bearer mock-openai-key"; got != want { t.Errorf("passthrough: upstream received Authorization = %q, want the provider's own configured key %q, not the client's", got, want) } req, err := http.NewRequest(http.MethodGet, traefik1URL+"/mcp/tool/sse", nil) if err != nil { t.Fatalf("new mcp request: %v", err) } req.Header.Set("Authorization", "Bearer "+aliceKey) mcpResp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("mcp target proxy request: %v", err) } defer func() { _ = mcpResp.Body.Close() }() if mcpResp.StatusCode != http.StatusOK { t.Fatalf("mcp target proxy: status = %d", mcpResp.StatusCode) } if ct := mcpResp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") { t.Errorf("mcp target proxy: Content-Type = %q, want text/event-stream", ct) } raw, err := io.ReadAll(mcpResp.Body) if err != nil { t.Fatalf("read mcp target proxy body: %v", err) } if !strings.Contains(string(raw), "mocktool") { t.Errorf("mcp target proxy: streamed body did not carry mocktool's content:\n%s", raw) } } // TestPassthroughModelAndPathRestriction (security review finding 2/3, // round 3, 2026-08-22 — closes a report-correction gap, not a code // defect: the reviewer verified peekPassthroughModel/scanTopLevelModel // and allowsPassthroughPath directly via the repo's own Yaegi harness, // but every group/user this suite drove native passthrough through left // models/passthroughPaths unset, so hasModelRestriction() was always // false and this suite itself never exercised either enforcement path // under a real Traefik+Yaegi process) drives dave (the "restricted" // group: models: ["gpt-mock"], passthroughPaths: ["v1/chat/completions"]) // through native passthrough three ways: an allowed model on the // allowed path (200, proxied for real), a denied model on that SAME // allowed path (403 — proves the model peek runs and rejects), and an // ALLOWED model on a path outside the allowlist (403 — proves // allowsPassthroughPath's non-empty-list branch runs and rejects; the // model on this third request is deliberately the allowed one, so a // pass here can only be explained by the path check, not the model // check, having fired). func TestPassthroughModelAndPathRestriction(t *testing.T) { allowedBody := map[string]any{ "model": "gpt-mock", "messages": []map[string]any{{"role": "user", "content": "hi"}}, } resp, body := doJSON(t, http.MethodPost, traefik1URL+"/openai/v1/chat/completions", daveKey, allowedBody) if resp.StatusCode != http.StatusOK { t.Fatalf("allowed model+path: status = %d, body=%#v", resp.StatusCode, body) } deniedModelBody := map[string]any{ "model": "not-in-the-allowlist", "messages": []map[string]any{{"role": "user", "content": "hi"}}, } resp, body = doJSON(t, http.MethodPost, traefik1URL+"/openai/v1/chat/completions", daveKey, deniedModelBody) if resp.StatusCode != http.StatusForbidden { t.Fatalf("denied model, allowed path: status = %d, want 403, body=%#v", resp.StatusCode, body) } // "v1/embeddings" is not in passthroughPaths (["v1/chat/completions"]); // the model ("gpt-mock") is the allowed one, so a 403 here can only // come from the path check, not the model check. deniedPathBody := map[string]any{"model": "gpt-mock", "input": "hi"} resp, body = doJSON(t, http.MethodPost, traefik1URL+"/openai/v1/embeddings", daveKey, deniedPathBody) if resp.StatusCode != http.StatusForbidden { t.Fatalf("allowed model, denied path: status = %d, want 403, body=%#v", resp.StatusCode, body) } } // TestFederatedMCP covers integration test 8 (SF4, review round 2, v0.21): // POST /mcp aggregates both the lenient "tool" server (a bare tools/list // answers outright) and the strict "toolstrict" server (rejects a bare // call with a JSON-RPC error, only answers after the plugin's own // bare-first-with-fallback handshake retry — mcp_federation.go's // mcpBackendCall) into one federated tools/list, then routes a tools/call // to the correct one by its "_" prefix — all under real // Traefik+Yaegi, not just go test's own mocks (closes the "only goroutine // in the package, zero real-interpreter coverage" review gap). func TestFederatedMCP(t *testing.T) { listReq := map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"} resp, body := doJSON(t, http.MethodPost, traefik1URL+"/mcp", aliceKey, listReq) if resp.StatusCode != http.StatusOK { t.Fatalf("tools/list: status = %d, body=%#v", resp.StatusCode, body) } if body["error"] != nil { t.Fatalf("tools/list: unexpected JSON-RPC error: %#v", body["error"]) } result, ok := body["result"].(map[string]any) if !ok { t.Fatalf("tools/list: result = %#v, want an object", body["result"]) } tools, ok := result["tools"].([]any) if !ok { t.Fatalf("tools/list: tools = %#v, want an array", result["tools"]) } var names []string for _, tool := range tools { toolMap, ok := tool.(map[string]any) if !ok { continue } if name, ok := toolMap["name"].(string); ok { names = append(names, name) } } if !slices.Contains(names, "tool_echo") { t.Errorf("tools/list names = %v, want tool_echo (the lenient server's bare tools/list)", names) } if !slices.Contains(names, "toolstrict_echo") { t.Errorf("tools/list names = %v, want toolstrict_echo (the strict server, reachable ONLY via the plugin's own handshake-fallback retry)", names) } callReq := map[string]any{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": map[string]any{"name": "toolstrict_echo", "arguments": map[string]any{"q": "hello"}}, } callResp, callBody := doJSON(t, http.MethodPost, traefik1URL+"/mcp", aliceKey, callReq) if callResp.StatusCode != http.StatusOK { t.Fatalf("tools/call: status = %d, body=%#v", callResp.StatusCode, callBody) } if callBody["error"] != nil { t.Fatalf("tools/call: unexpected JSON-RPC error: %#v", callBody["error"]) } callResult, ok := callBody["result"].(map[string]any) if !ok { t.Fatalf("tools/call: result = %#v, want an object", callBody["result"]) } if got, want := callResult["echoed"], "echo"; got != want { t.Errorf("tools/call: echoed = %v, want %q (the server-name prefix must be stripped before forwarding to toolstrict)", got, want) } } // TestRealUpstreamSmoke covers integration test 7 (optional): a live round // trip through a second middleware config carrying a keyless openai-type // "uni" provider pointed at the operator's real gateway. Gated on // INTEGRATION_REAL=1 and skipped, rather than failed, on any network or // upstream problem — this is a smoke test of live infrastructure this // suite does not own, not a correctness check on the plugin. func TestRealUpstreamSmoke(t *testing.T) { if os.Getenv("INTEGRATION_REAL") != "1" { t.Skip("INTEGRATION_REAL not set to 1; skipping real-upstream smoke test") } reqBody := map[string]any{ "model": "uni/deepseek-v4-flash-0731", "messages": []map[string]any{{"role": "user", "content": "Say hi in one word."}}, } b, err := json.Marshal(reqBody) if err != nil { t.Fatalf("marshal request: %v", err) } req, err := http.NewRequest(http.MethodPost, traefik1URL+"/v1/chat/completions", bytes.NewReader(b)) if err != nil { t.Fatalf("new request: %v", err) } req.Host = "real.localhost" // routes through llmgateway-real-router's Host() rule req.Header.Set("Authorization", "Bearer sk-int-real") req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { t.Skipf("real upstream unreachable, skipping: %v", err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read real upstream response: %v", err) } if resp.StatusCode != http.StatusOK { t.Skipf("real upstream returned status %d, skipping (treated as upstream flake, not a plugin bug): %s", resp.StatusCode, raw) } var body map[string]any if err := json.Unmarshal(raw, &body); err != nil { t.Fatalf("decode real upstream response: %v\nraw=%s", err, raw) } if content := firstChoiceContent(t, body); strings.TrimSpace(content) == "" { t.Errorf("real upstream: response content is empty, body=%#v", body) } } // --- v0.2: retry, cache, images/audio, admin (task 5) --- // TestRetryFlakyRecovers covers v0.2 integration coverage: the "flaky" // mock provider (dynamic.yml.tmpl) fails its first request with 503 and // succeeds on its second; with retry.enabled (dynamic.yml.tmpl's // top-level retry block) the client sees a single 200. The mock's own // "mock_attempt" response field — forwarded verbatim by the openai-type // adapter's passthrough (forwardJSON, provider_openai.go) — proves the // retry made exactly two upstream tries, not merely that the mock // happened to succeed on a fresh process. // // Resets the mock's hit counter first via POST /flaky/reset — reachable // through the "flaky" provider's own native passthrough route // (routes_passthrough.go), which reaches handleFlakyReset // (integration/mock/main.go) — so this test reproduces the same // forced-503-then-success sequence on every run, including a re-run // against an already-up `make integration-keep` stack with no // mockopenai restart. func TestRetryFlakyRecovers(t *testing.T) { resetResp, resetBody := doJSON(t, http.MethodPost, traefik1URL+"/flaky/reset", aliceKey, nil) if resetResp.StatusCode != http.StatusOK { t.Fatalf("reset flaky counter: status = %d, body=%#v", resetResp.StatusCode, resetBody) } reqBody := map[string]any{ "model": "flaky/flaky-mock", "messages": []map[string]any{{"role": "user", "content": "hi"}}, } resp, body := doJSON(t, http.MethodPost, traefik1URL+"/v1/chat/completions", aliceKey, reqBody) if resp.StatusCode != http.StatusOK { t.Fatalf("flaky chat: status = %d, want 200 (retry should have recovered the forced 503), body=%#v", resp.StatusCode, body) } if content := firstChoiceContent(t, body); content != "mock flaky response" { t.Errorf("flaky chat: content = %q, want %q", content, "mock flaky response") } attempt, ok := body["mock_attempt"].(float64) if !ok { t.Fatalf("flaky chat: response has no numeric mock_attempt field, body=%#v", body) } if attempt != 2 { t.Errorf("flaky chat: mock_attempt = %v, want 2 (one forced 503 plus one retry success = exactly two upstream hits)", attempt) } } // adminUsageEntry issues GET /admin/api/usage as adminKey and returns the // named user's or group's raw JSON entry. kind is "users" or "groups" — // the top-level keys adminUsageResponse (admin.go) marshals to. func adminUsageEntry(t *testing.T, kind, id string) map[string]any { t.Helper() resp, body := doJSON(t, http.MethodGet, traefik1URL+"/admin/api/usage", adminKey, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("GET /admin/api/usage: status = %d, body=%#v", resp.StatusCode, body) } entries, ok := body[kind].([]any) if !ok { t.Fatalf("GET /admin/api/usage: body[%q] is not an array, body=%#v", kind, body) } for _, e := range entries { entry, ok := e.(map[string]any) if ok && entry["id"] == id { return entry } } t.Fatalf("GET /admin/api/usage: no %q entry for id %q, body=%#v", kind, id, body) return nil } // adminProviderModels finds name's own "models" array within providers (an // already-decoded GET /admin/api/overview "providers" list) and returns it // as a []string — the provider-model-accordion feature's field // (adminProviderView.Models, admin.go). func adminProviderModels(t *testing.T, providers []any, name string) []string { t.Helper() for _, p := range providers { provider, ok := p.(map[string]any) if !ok || provider["name"] != name { continue } raw, ok := provider["models"].([]any) if !ok { t.Fatalf("provider %q: models is not an array, got %#v", name, provider["models"]) } out := make([]string, len(raw)) for i, m := range raw { out[i], ok = m.(string) if !ok { t.Fatalf("provider %q: models[%d] is not a string, got %#v", name, i, m) } } return out } t.Fatalf("no provider named %q in %#v", name, providers) return nil } // adminTotalTokensPerDay sums an admin usage entry's tokensInPerDay and // tokensOutPerDay (v0.2 data-layer task: the JSON API split what was // previously a single combined tokensPerDay field into in/out — see // adminUsageEntryView, admin.go). func adminTotalTokensPerDay(entry map[string]any) float64 { in, _ := entry["tokensInPerDay"].(float64) out, _ := entry["tokensOutPerDay"].(float64) return in + out } // adminUsageHistory issues GET /admin/api/usage/history as adminKey for // scope/metric/window and returns the decoded response body — the v0.2 // data-layer task's third admin API. func adminUsageHistory(t *testing.T, scope, metric, window string) map[string]any { t.Helper() url := traefik1URL + "/admin/api/usage/history?scope=" + scope + "&metric=" + metric + "&window=" + window resp, body := doJSON(t, http.MethodGet, url, adminKey, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("GET /admin/api/usage/history (scope=%s metric=%s window=%s): status = %d, body=%#v", scope, metric, window, resp.StatusCode, body) } return body } // TestResponseCache covers v0.2 integration coverage: two identical chat // completions get X-Llmgw-Cache: miss then hit, and the admin usage API // (spec §4) proves the hit added zero tokens — only the miss's real // upstream call did (spec §2's "cached hits increment request counters, // never token/cost counters"). The request content is a unique string // found nowhere else in this suite, so this test's result never depends // on what any other test cached first. func TestResponseCache(t *testing.T) { reqBody := map[string]any{ "model": "openai/gpt-mock", "messages": []map[string]any{{"role": "user", "content": "cache-probe-8f2c1a9d"}}, } resp1, body1 := doJSON(t, http.MethodPost, traefik1URL+"/v1/chat/completions", aliceKey, reqBody) if resp1.StatusCode != http.StatusOK { t.Fatalf("first (miss) request: status = %d, body=%#v", resp1.StatusCode, body1) } if got := resp1.Header.Get("X-Llmgw-Cache"); got != "miss" { t.Fatalf("first request: X-Llmgw-Cache = %q, want %q", got, "miss") } if content := firstChoiceContent(t, body1); content != "mock openai response" { t.Errorf("first request: content = %q, want %q", content, "mock openai response") } if keys := redisKeys(t, "llmgw:cache:*"); len(keys) == 0 { t.Error("redis holds no llmgw:cache:* key after a cache miss, want the entry the miss's SET (cache.go) should have written") } tokensAfterMiss := adminTotalTokensPerDay(adminUsageEntry(t, "users", "alice")) if tokensAfterMiss <= 0 { t.Fatalf("tokensInPerDay+tokensOutPerDay after the miss = %v, want > 0 (the mock's real usage should have been accounted)", tokensAfterMiss) } resp2, body2 := doJSON(t, http.MethodPost, traefik1URL+"/v1/chat/completions", aliceKey, reqBody) if resp2.StatusCode != http.StatusOK { t.Fatalf("second (hit) request: status = %d, body=%#v", resp2.StatusCode, body2) } if got := resp2.Header.Get("X-Llmgw-Cache"); got != "hit" { t.Fatalf("second request: X-Llmgw-Cache = %q, want %q", got, "hit") } if content := firstChoiceContent(t, body2); content != "mock openai response" { t.Errorf("second request: content = %q, want the cached %q", content, "mock openai response") } tokensAfterHit := adminTotalTokensPerDay(adminUsageEntry(t, "users", "alice")) if tokensAfterHit != tokensAfterMiss { t.Errorf("tokensInPerDay+tokensOutPerDay changed across the cache hit: after miss = %v, after hit = %v, want unchanged", tokensAfterMiss, tokensAfterHit) } } // TestImagesGenerations covers v0.2 integration coverage: POST // /v1/images/generations native-forwards through the mock openai // upstream, and translates through Imagen's :predict for a gemini-routed // model (translate_gemini_images.go). func TestImagesGenerations(t *testing.T) { openaiReq := map[string]any{ "model": "openai/gpt-mock", "prompt": "a cat", "n": 1, "size": "1024x1024", } resp, body := doJSON(t, http.MethodPost, traefik1URL+"/v1/images/generations", aliceKey, openaiReq) if resp.StatusCode != http.StatusOK { t.Fatalf("openai images: status = %d, body=%#v", resp.StatusCode, body) } data, ok := body["data"].([]any) if !ok || len(data) == 0 { t.Fatalf("openai images: expected a non-empty data array, got %#v", body) } geminiReq := map[string]any{ "model": "gemini/gemini-mock", "prompt": "a cat", "n": 1, "size": "1024x1024", } resp2, body2 := doJSON(t, http.MethodPost, traefik1URL+"/v1/images/generations", aliceKey, geminiReq) if resp2.StatusCode != http.StatusOK { t.Fatalf("gemini images: status = %d, body=%#v", resp2.StatusCode, body2) } data2, ok := body2["data"].([]any) if !ok || len(data2) == 0 { t.Fatalf("gemini images: expected a non-empty data array translated from Imagen predictions, got %#v", body2) } first, ok := data2[0].(map[string]any) if !ok { t.Fatalf("gemini images: data[0] is not an object: %#v", data2[0]) } if b64, _ := first["b64_json"].(string); b64 == "" { t.Errorf("gemini images: data[0].b64_json is empty, want the mock's Imagen bytesBase64Encoded translated through") } } // TestAudioSpeech covers v0.2 integration coverage: POST /v1/audio/speech // round-trips a binary response byte-for-byte, with its Content-Type // forwarded (audioSpeech, provider_openai.go). func TestAudioSpeech(t *testing.T) { reqBody := map[string]any{ "model": "openai/gpt-mock", "input": "hello", "voice": "alloy", } b, err := json.Marshal(reqBody) if err != nil { t.Fatalf("marshal request: %v", err) } req, err := http.NewRequest(http.MethodPost, traefik1URL+"/v1/audio/speech", bytes.NewReader(b)) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Authorization", "Bearer "+aliceKey) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("audio speech request: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("audio speech: status = %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); ct != "audio/mpeg" { t.Errorf("audio speech: Content-Type = %q, want %q", ct, "audio/mpeg") } raw, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read audio speech body: %v", err) } const want = "MOCK-AUDIO-BYTES-0123456789-DISTINCTIVE-PAYLOAD" if string(raw) != want { t.Errorf("audio speech: body = %q, want the mock's exact bytes %q", raw, want) } } // TestAudioTranscriptions covers the review wave's highest-value Yaegi gap: // POST /v1/audio/transcriptions' multipart handling under real // Traefik+Yaegi. A provider-prefixed model ("openai/gpt-mock") drives // rewriteMultipartModel (routes_media.go), which rebuilds the multipart // body with the "model" part rewritten to the resolved bare upstream id // before it reaches the mock — asserted via mock/main.go's // handleOpenAIAudioTranscriptions echoing "model_received" back. A second, // already-bare request ("gpt-mock") exercises the byte-identical replay // path instead: no rebuild, same bare id reported back verbatim. func TestAudioTranscriptions(t *testing.T) { t.Run("provider-prefixed model is rewritten before upload", func(t *testing.T) { body, contentType := buildMultipartAudio(t, "openai/gpt-mock") resp, decoded := doMultipart(t, traefik1URL+"/v1/audio/transcriptions", aliceKey, contentType, body) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, body=%#v", resp.StatusCode, decoded) } if got, _ := decoded["model_received"].(string); got != "gpt-mock" { t.Errorf("model_received = %q, want %q (rewritten from the provider-prefixed id)", got, "gpt-mock") } }) t.Run("bare model replays the original body unchanged", func(t *testing.T) { body, contentType := buildMultipartAudio(t, "gpt-mock") resp, decoded := doMultipart(t, traefik1URL+"/v1/audio/transcriptions", aliceKey, contentType, body) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, body=%#v", resp.StatusCode, decoded) } if got, _ := decoded["model_received"].(string); got != "gpt-mock" { t.Errorf("model_received = %q, want %q (byte-identical replay, no rewrite needed)", got, "gpt-mock") } }) } // adminCSPHeader mirrors admin.go's adminCSP constant byte-for-byte. This // package is a separate Go module (integration/go.mod) and cannot import // the plugin's unexported const, so the value is duplicated by hand; // TestAdminAssetCSP below compares it against a real header the running // plugin sent, so a drift between the two shows up as a test failure // immediately, not as a silent divergence. const adminCSPHeader = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" // adminAssetSrcRe extracts the `src` attribute of the first `