build / build (push) Successful in 2m31s
Record the last 10 /v1/ API requests (including errors) in an in-memory ring buffer. Each entry captures request headers (Authorization masked), request body, response status, and response body — all up to 1 MB. - recorder.go: RecentRecorder ring buffer, recordingResponseWriter, withRecording middleware, getRecent handler - server.go: add recorder to Server, wrap /v1/ routes, add /api/recent - admin.html: new 最近请求 tab with lazy-load and expandable cards - recorder_test.go: 5 tests (ring buffer, ordering, capture, errors, API)
149 lines
4.7 KiB
Go
149 lines
4.7 KiB
Go
package server
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestRecentRecorderRingBuffer verifies that the recorder keeps at most
|
|
// maxRecentEntries and evicts the oldest when full.
|
|
func TestRecentRecorderRingBuffer(t *testing.T) {
|
|
rec := NewRecentRecorder()
|
|
for i := 0; i < maxRecentEntries+5; i++ {
|
|
rec.Record(RecentEntry{Method: "POST", Path: "/v1/test", Status: 200})
|
|
}
|
|
entries := rec.Entries()
|
|
if len(entries) != maxRecentEntries {
|
|
t.Fatalf("got %d entries, want %d", len(entries), maxRecentEntries)
|
|
}
|
|
}
|
|
|
|
// TestRecentRecorderNewestFirst verifies entries are returned newest-first.
|
|
func TestRecentRecorderNewestFirst(t *testing.T) {
|
|
rec := NewRecentRecorder()
|
|
rec.Record(RecentEntry{Path: "/first"})
|
|
rec.Record(RecentEntry{Path: "/second"})
|
|
rec.Record(RecentEntry{Path: "/third"})
|
|
entries := rec.Entries()
|
|
if len(entries) != 3 {
|
|
t.Fatalf("got %d entries", len(entries))
|
|
}
|
|
if entries[0].Path != "/third" {
|
|
t.Fatalf("first entry = %q, want /third", entries[0].Path)
|
|
}
|
|
if entries[2].Path != "/first" {
|
|
t.Fatalf("last entry = %q, want /first", entries[2].Path)
|
|
}
|
|
}
|
|
|
|
// TestWithRecordingCapturesRequestResponse verifies the middleware captures
|
|
// request headers, request body, response status, and response body.
|
|
func TestWithRecordingCapturesRequestResponse(t *testing.T) {
|
|
rec := NewRecentRecorder()
|
|
s := &Server{recorder: rec}
|
|
|
|
handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"result":"ok"}`))
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"GLM-4.7","messages":[]}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer sk-secret-key-12345")
|
|
req.Header.Set("User-Agent", "test-client/1.0")
|
|
w := httptest.NewRecorder()
|
|
handler(w, req)
|
|
|
|
entries := rec.Entries()
|
|
if len(entries) != 1 {
|
|
t.Fatalf("got %d entries, want 1", len(entries))
|
|
}
|
|
e := entries[0]
|
|
if e.Method != "POST" {
|
|
t.Fatalf("method = %q", e.Method)
|
|
}
|
|
if e.Path != "/v1/chat/completions" {
|
|
t.Fatalf("path = %q", e.Path)
|
|
}
|
|
if e.Status != 200 {
|
|
t.Fatalf("status = %d", e.Status)
|
|
}
|
|
if e.RequestBody != `{"model":"GLM-4.7","messages":[]}` {
|
|
t.Fatalf("request body = %q", e.RequestBody)
|
|
}
|
|
if e.ResponseBody != `{"result":"ok"}` {
|
|
t.Fatalf("response body = %q", e.ResponseBody)
|
|
}
|
|
// Authorization must be masked
|
|
auth, ok := e.RequestHeaders["Authorization"]
|
|
if !ok || !strings.Contains(auth, "****") {
|
|
t.Fatalf("authorization not masked: %q", auth)
|
|
}
|
|
if strings.Contains(auth, "sk-secret-key-12345") {
|
|
t.Fatal("authorization leaked raw key")
|
|
}
|
|
if e.RequestHeaders["Content-Type"] != "application/json" {
|
|
t.Fatalf("content-type = %v", e.RequestHeaders["Content-Type"])
|
|
}
|
|
if e.RequestHeaders["User-Agent"] != "test-client/1.0" {
|
|
t.Fatalf("user-agent = %v", e.RequestHeaders["User-Agent"])
|
|
}
|
|
}
|
|
|
|
// TestWithRecordingCapturesErrors verifies error responses are recorded.
|
|
func TestWithRecordingCapturesErrors(t *testing.T) {
|
|
rec := NewRecentRecorder()
|
|
s := &Server{recorder: rec}
|
|
|
|
handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) {
|
|
writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"input":"hi"}`))
|
|
req.Header.Set("Authorization", "Bearer wrong-key")
|
|
w := httptest.NewRecorder()
|
|
handler(w, req)
|
|
|
|
entries := rec.Entries()
|
|
if len(entries) != 1 {
|
|
t.Fatalf("got %d entries, want 1", len(entries))
|
|
}
|
|
e := entries[0]
|
|
if e.Status != 401 {
|
|
t.Fatalf("status = %d, want 401", e.Status)
|
|
}
|
|
if !strings.Contains(e.ResponseBody, "invalid api key") {
|
|
t.Fatalf("response body = %q", e.ResponseBody)
|
|
}
|
|
}
|
|
|
|
// TestGetRecentAPI verifies GET /api/recent returns recorded entries as JSON.
|
|
func TestGetRecentAPI(t *testing.T) {
|
|
rec := NewRecentRecorder()
|
|
rec.Record(RecentEntry{Method: "POST", Path: "/v1/chat/completions", Status: 200})
|
|
rec.Record(RecentEntry{Method: "POST", Path: "/v1/responses", Status: 500})
|
|
s := &Server{recorder: rec}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/recent", nil)
|
|
w := httptest.NewRecorder()
|
|
s.getRecent(w, req)
|
|
|
|
body, _ := io.ReadAll(w.Body)
|
|
if !strings.Contains(string(body), `"ok":true`) {
|
|
t.Fatalf("response missing ok:true: %s", string(body))
|
|
}
|
|
if !strings.Contains(string(body), "/v1/responses") {
|
|
t.Fatalf("response missing /v1/responses: %s", string(body))
|
|
}
|
|
// newest first
|
|
idxResponses := strings.Index(string(body), "/v1/responses")
|
|
idxChat := strings.Index(string(body), "/v1/chat/completions")
|
|
if idxResponses < 0 || idxChat < 0 || idxResponses > idxChat {
|
|
t.Fatalf("entries not newest-first: %s", string(body))
|
|
}
|
|
}
|