433 lines
16 KiB
Go
433 lines
16 KiB
Go
package control
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gpt-load/internal/platform/config"
|
|
"gpt-load/internal/requestlog"
|
|
)
|
|
|
|
func TestUsageAPIRouteUsesManagementAuthentication(t *testing.T) {
|
|
initControlI18n(t)
|
|
fixture := newServiceFixture(t)
|
|
fixture.service.now = func() time.Time {
|
|
return time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC)
|
|
}
|
|
fixture.service.usageStats = &recordingUsageStatReader{}
|
|
engine := gin.New()
|
|
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/api/usage", nil)
|
|
request.Header.Set("Authorization", "Bearer test-auth-key")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("GET /api/usage = %d %s, want 200", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestParseUsageQueryUsesFixedUTCAlignedWindows(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
rawQuery string
|
|
observedAt time.Time
|
|
wantFrom time.Time
|
|
wantTo time.Time
|
|
granularity requestlog.UsageGranularity
|
|
}{
|
|
{
|
|
name: "24 hours crosses the local and UTC day at an exact hour boundary",
|
|
rawQuery: "range=24h",
|
|
observedAt: time.Date(2026, time.July, 27, 0, 34, 56, 789, time.FixedZone("UTC+8", 8*60*60)),
|
|
wantFrom: time.Date(2026, time.July, 25, 17, 0, 0, 0, time.UTC),
|
|
wantTo: time.Date(2026, time.July, 26, 17, 0, 0, 0, time.UTC),
|
|
granularity: requestlog.UsageGranularityHour,
|
|
},
|
|
{
|
|
name: "30 days crosses the local and UTC day at exact day boundaries",
|
|
rawQuery: "range=30d",
|
|
observedAt: time.Date(2026, time.July, 26, 20, 34, 56, 789, time.FixedZone("UTC-7", -7*60*60)),
|
|
wantFrom: time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC),
|
|
wantTo: time.Date(2026, time.July, 28, 0, 0, 0, 0, time.UTC),
|
|
granularity: requestlog.UsageGranularityDay,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
query, apiErr := parseUsageQuery(test.rawQuery, test.observedAt)
|
|
if apiErr != nil {
|
|
t.Fatalf("parseUsageQuery() error = %v", apiErr)
|
|
}
|
|
if !query.From.Equal(test.wantFrom) || !query.To.Equal(test.wantTo) ||
|
|
query.Granularity != test.granularity {
|
|
t.Fatalf(
|
|
"parseUsageQuery() window = %s to %s (%s), want %s to %s (%s)",
|
|
query.From.Format(time.RFC3339Nano),
|
|
query.To.Format(time.RFC3339Nano),
|
|
query.Granularity,
|
|
test.wantFrom.Format(time.RFC3339Nano),
|
|
test.wantTo.Format(time.RFC3339Nano),
|
|
test.granularity,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing.T) {
|
|
now := time.Date(2026, time.July, 27, 12, 34, 56, 789, time.FixedZone("UTC+8", 8*60*60))
|
|
reader := &recordingUsageStatReader{}
|
|
engine, fixture := newUsageTestEngine(t, now, reader)
|
|
fixture.requestLogStats.value.DroppedTotal = 2
|
|
fixture.requestLogStats.value.WriteFailureTotal = 1
|
|
fixture.requestLogStats.value.LastWriteFailureAt = time.Date(
|
|
2026, time.July, 27, 3, 0, 0, 0, time.UTC,
|
|
)
|
|
|
|
recorder := performUsageRequest(engine, "test-auth-key", "")
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(reader.queries) != 1 {
|
|
t.Fatalf("QueryUsage calls = %d, want one", len(reader.queries))
|
|
}
|
|
query := reader.queries[0]
|
|
if query.Granularity != requestlog.UsageGranularityHour ||
|
|
query.From.Format(time.RFC3339Nano) != "2026-07-26T05:00:00Z" ||
|
|
query.To.Format(time.RFC3339Nano) != "2026-07-27T05:00:00Z" ||
|
|
query.GroupID != nil || query.Model != "" || query.Limit != 100 {
|
|
t.Fatalf("default UsageQuery = %#v", query)
|
|
}
|
|
var envelope struct {
|
|
Code int `json:"code"`
|
|
Data struct {
|
|
Range string `json:"range"`
|
|
Granularity requestlog.UsageGranularity `json:"granularity"`
|
|
Timezone string `json:"timezone"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
ObservedAt string `json:"observed_at"`
|
|
Series []json.RawMessage `json:"series"`
|
|
Breakdown []json.RawMessage `json:"breakdown"`
|
|
Health struct {
|
|
Scope string `json:"scope"`
|
|
DroppedTotal uint64 `json:"dropped_total"`
|
|
WriteFailureTotal uint64 `json:"write_failure_total"`
|
|
LastWriteFailureAt *time.Time `json:"last_write_failure_at"`
|
|
} `json:"collection_health"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if envelope.Code != 0 || envelope.Data.Range != "24h" ||
|
|
envelope.Data.Granularity != requestlog.UsageGranularityHour ||
|
|
envelope.Data.Timezone != "UTC" ||
|
|
envelope.Data.From != "2026-07-26T05:00:00Z" ||
|
|
envelope.Data.To != "2026-07-27T05:00:00Z" ||
|
|
envelope.Data.ObservedAt != "2026-07-27T04:34:56.000000789Z" ||
|
|
envelope.Data.Series == nil || len(envelope.Data.Series) != 0 ||
|
|
envelope.Data.Breakdown == nil || len(envelope.Data.Breakdown) != 0 ||
|
|
envelope.Data.Health.Scope != "current_process" ||
|
|
envelope.Data.Health.DroppedTotal != 2 ||
|
|
envelope.Data.Health.WriteFailureTotal != 1 ||
|
|
envelope.Data.Health.LastWriteFailureAt == nil ||
|
|
envelope.Data.Health.LastWriteFailureAt.Format(time.RFC3339Nano) != "2026-07-27T03:00:00Z" {
|
|
t.Fatalf("default usage envelope = %#v", envelope)
|
|
}
|
|
var rawEnvelope struct {
|
|
Data map[string]json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &rawEnvelope); err != nil {
|
|
t.Fatalf("decode raw response: %v", err)
|
|
}
|
|
for _, forbidden := range []string{"filters", "request_log"} {
|
|
if _, exists := rawEnvelope.Data[forbidden]; exists {
|
|
t.Fatalf("usage response exposes forbidden %q field: %s", forbidden, recorder.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) {
|
|
now := time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC)
|
|
reader := &recordingUsageStatReader{report: requestlog.UsageReport{
|
|
Summary: requestlog.UsageAggregate{RequestCount: 1, UncachedInputTokens: 2, OutputTokens: 3, Cost: math.Copysign(0, -1)},
|
|
Series: []requestlog.UsageSeriesPoint{{
|
|
BucketStart: time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC),
|
|
BucketEnd: time.Date(2026, time.June, 29, 0, 0, 0, 0, time.UTC),
|
|
UsageAggregate: requestlog.UsageAggregate{
|
|
RequestCount: 1, UncachedInputTokens: 2, OutputTokens: 3, Cost: 1.1234567890123,
|
|
},
|
|
}},
|
|
Breakdown: []requestlog.UsageBreakdown{{
|
|
GroupID: 9, Model: "upstream-model",
|
|
UsageAggregate: requestlog.UsageAggregate{RequestCount: 1, UncachedInputTokens: 2, OutputTokens: 3, Cost: 0.25},
|
|
}},
|
|
}}
|
|
engine, _ := newUsageTestEngine(t, now, reader)
|
|
recorder := performUsageRequest(engine, "test-auth-key", "range=30d&group_id=9&model=upstream-model")
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(reader.queries) != 1 {
|
|
t.Fatalf("QueryUsage calls = %d, want one", len(reader.queries))
|
|
}
|
|
query := reader.queries[0]
|
|
if query.Granularity != requestlog.UsageGranularityDay ||
|
|
query.From.Format(time.RFC3339Nano) != "2026-06-28T00:00:00Z" ||
|
|
query.To.Format(time.RFC3339Nano) != "2026-07-28T00:00:00Z" ||
|
|
query.GroupID == nil || *query.GroupID != 9 ||
|
|
query.Model != "upstream-model" || query.Limit != 100 {
|
|
t.Fatalf("30 day UsageQuery = %#v", query)
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
Range string `json:"range"`
|
|
Granularity requestlog.UsageGranularity `json:"granularity"`
|
|
Timezone string `json:"timezone"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Summary struct {
|
|
TotalTokens int64 `json:"total_tokens"`
|
|
EstimatedCostUSD json.Number `json:"estimated_cost_usd"`
|
|
} `json:"summary"`
|
|
Series []struct {
|
|
BucketStart string `json:"bucket_start"`
|
|
TotalTokens int64 `json:"total_tokens"`
|
|
EstimatedCostUSD json.Number `json:"estimated_cost_usd"`
|
|
} `json:"series"`
|
|
Breakdown []struct {
|
|
GroupID uint `json:"group_id"`
|
|
Model string `json:"model"`
|
|
Cost json.Number `json:"estimated_cost_usd"`
|
|
} `json:"breakdown"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if envelope.Data.Range != "30d" ||
|
|
envelope.Data.Granularity != requestlog.UsageGranularityDay ||
|
|
envelope.Data.Timezone != "UTC" ||
|
|
envelope.Data.From != "2026-06-28T00:00:00Z" ||
|
|
envelope.Data.To != "2026-07-28T00:00:00Z" ||
|
|
envelope.Data.Summary.TotalTokens != 5 ||
|
|
envelope.Data.Summary.EstimatedCostUSD.String() != "0" ||
|
|
len(envelope.Data.Series) != 1 || envelope.Data.Series[0].BucketStart != "2026-06-28T00:00:00Z" ||
|
|
envelope.Data.Series[0].TotalTokens != 5 || envelope.Data.Series[0].EstimatedCostUSD.String() != "1.12345678901" ||
|
|
len(envelope.Data.Breakdown) != 1 || envelope.Data.Breakdown[0].GroupID != 9 ||
|
|
envelope.Data.Breakdown[0].Model != "upstream-model" || envelope.Data.Breakdown[0].Cost.String() != "0.25" {
|
|
t.Fatalf("usage response = %#v", envelope.Data)
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIValidatesModelAsUTF8BytesWithoutBoundaryWhitespaceOrControls(t *testing.T) {
|
|
reader := &recordingUsageStatReader{}
|
|
engine, _ := newUsageTestEngine(
|
|
t,
|
|
time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC),
|
|
reader,
|
|
)
|
|
validModels := []string{
|
|
"a",
|
|
strings.Repeat("a", 252) + "猫",
|
|
"model variant",
|
|
}
|
|
for _, model := range validModels {
|
|
recorder := performUsageRequest(engine, "test-auth-key", "model="+url.QueryEscape(model))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("valid model %q response = %d %s", model, recorder.Code, recorder.Body.String())
|
|
}
|
|
if got := reader.queries[len(reader.queries)-1].Model; got != model {
|
|
t.Fatalf("reader model = %q, want %q", got, model)
|
|
}
|
|
}
|
|
validCalls := len(reader.queries)
|
|
|
|
invalidModels := []struct {
|
|
name string
|
|
query string
|
|
}{
|
|
{name: "invalid UTF-8", query: "model=%FF"},
|
|
{name: "leading whitespace", query: "model=" + url.QueryEscape(" model")},
|
|
{name: "trailing whitespace", query: "model=" + url.QueryEscape("model\u3000")},
|
|
{name: "embedded control", query: "model=" + url.QueryEscape("model\x00id")},
|
|
{name: "DEL control", query: "model=" + url.QueryEscape("model\x7fid")},
|
|
{name: "256 UTF-8 bytes", query: "model=" + url.QueryEscape(strings.Repeat("a", 253)+"猫")},
|
|
}
|
|
for _, test := range invalidModels {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
recorder := performUsageRequest(engine, "test-auth-key", test.query)
|
|
assertUsageErrorCode(t, recorder, "VALIDATION_FAILED")
|
|
})
|
|
}
|
|
if len(reader.queries) != validCalls {
|
|
t.Fatalf("reader calls = %d, want %d after invalid models", len(reader.queries), validCalls)
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIRejectsStrictInvalidQueriesWithoutCallingReader(t *testing.T) {
|
|
reader := &recordingUsageStatReader{}
|
|
engine, _ := newUsageTestEngine(t, time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), reader)
|
|
tests := []struct {
|
|
query string
|
|
code string
|
|
}{
|
|
{query: "unknown=1", code: "BAD_REQUEST"},
|
|
{query: "range=24h&range=30d", code: "BAD_REQUEST"},
|
|
{query: "range=1h", code: "VALIDATION_FAILED"},
|
|
{query: "group_id=0", code: "VALIDATION_FAILED"},
|
|
{query: "group_id=9007199254740992", code: "VALIDATION_FAILED"},
|
|
{query: "group_id=-1", code: "BAD_REQUEST"},
|
|
{query: "model=", code: "VALIDATION_FAILED"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.query, func(t *testing.T) {
|
|
recorder := performUsageRequest(engine, "test-auth-key", test.query)
|
|
assertUsageErrorCode(t, recorder, test.code)
|
|
})
|
|
}
|
|
if len(reader.queries) != 0 {
|
|
t.Fatalf("QueryUsage calls = %d, want zero", len(reader.queries))
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIRejectsUnsafeAggregateAndKeepsErrorsSecret(t *testing.T) {
|
|
reader := &recordingUsageStatReader{report: requestlog.UsageReport{
|
|
Summary: requestlog.UsageAggregate{RequestCount: 9_007_199_254_740_992},
|
|
}}
|
|
engine, _ := newUsageTestEngine(t, time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), reader)
|
|
recorder := performUsageRequest(engine, "test-auth-key", "")
|
|
if recorder.Code != http.StatusInternalServerError ||
|
|
!strings.Contains(recorder.Body.String(), "INTERNAL_SERVER_ERROR") ||
|
|
strings.Contains(recorder.Body.String(), "unsafe") {
|
|
t.Fatalf("unsafe response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
reader.err = errors.New("usage database secret")
|
|
reader.report = requestlog.UsageReport{}
|
|
recorder = performUsageRequest(engine, "test-auth-key", "")
|
|
if recorder.Code != http.StatusInternalServerError || strings.Contains(recorder.Body.String(), "usage database secret") {
|
|
t.Fatalf("reader error response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIRejectsUnsafeProcessStatsWithoutLeakingCause(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*requestlog.Stats)
|
|
}{
|
|
{name: "dropped total", mutate: func(stats *requestlog.Stats) { stats.DroppedTotal = uint64(maxSafeInteger) + 1 }},
|
|
{name: "write failure", mutate: func(stats *requestlog.Stats) { stats.WriteFailureTotal = uint64(maxSafeInteger) + 1 }},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
engine, fixture := newUsageTestEngine(
|
|
t,
|
|
time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC),
|
|
&recordingUsageStatReader{},
|
|
)
|
|
test.mutate(&fixture.requestLogStats.value)
|
|
recorder := performUsageRequest(engine, "test-auth-key", "")
|
|
if recorder.Code != http.StatusInternalServerError ||
|
|
!strings.Contains(recorder.Body.String(), "INTERNAL_SERVER_ERROR") ||
|
|
strings.Contains(strings.ToLower(recorder.Body.String()), "queue") ||
|
|
strings.Contains(strings.ToLower(recorder.Body.String()), "safe") {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIRejectsUnattributedBreakdownWithoutLeakingCause(t *testing.T) {
|
|
reader := &recordingUsageStatReader{report: requestlog.UsageReport{
|
|
Breakdown: []requestlog.UsageBreakdown{{GroupID: 0, Model: "unattributed"}},
|
|
}}
|
|
engine, _ := newUsageTestEngine(t, time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), reader)
|
|
recorder := performUsageRequest(engine, "test-auth-key", "")
|
|
if recorder.Code != http.StatusInternalServerError ||
|
|
!strings.Contains(recorder.Body.String(), "INTERNAL_SERVER_ERROR") ||
|
|
strings.Contains(strings.ToLower(recorder.Body.String()), "group") {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIRequiresManagementAuthentication(t *testing.T) {
|
|
engine, _ := newUsageTestEngine(t, time.Now(), &recordingUsageStatReader{})
|
|
recorder := performUsageRequest(engine, "", "")
|
|
if recorder.Code != http.StatusUnauthorized {
|
|
t.Fatalf("response = %d %s, want 401", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
type recordingUsageStatReader struct {
|
|
queries []requestlog.UsageQuery
|
|
report requestlog.UsageReport
|
|
err error
|
|
}
|
|
|
|
func (reader *recordingUsageStatReader) QueryUsage(
|
|
_ context.Context,
|
|
query requestlog.UsageQuery,
|
|
) (requestlog.UsageReport, error) {
|
|
reader.queries = append(reader.queries, query)
|
|
if reader.err != nil {
|
|
return requestlog.UsageReport{}, reader.err
|
|
}
|
|
return reader.report, nil
|
|
}
|
|
|
|
func newUsageTestEngine(
|
|
t *testing.T,
|
|
now time.Time,
|
|
reader *recordingUsageStatReader,
|
|
) (*gin.Engine, serviceFixture) {
|
|
t.Helper()
|
|
initControlI18n(t)
|
|
fixture := newServiceFixture(t)
|
|
fixture.service.now = func() time.Time { return now }
|
|
fixture.service.usageStats = reader
|
|
engine := gin.New()
|
|
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
|
|
return engine, fixture
|
|
}
|
|
|
|
func performUsageRequest(engine *gin.Engine, authKey, query string) *httptest.ResponseRecorder {
|
|
target := "/api/usage"
|
|
if query != "" {
|
|
target += "?" + query
|
|
}
|
|
request := httptest.NewRequest(http.MethodGet, target, nil)
|
|
if authKey != "" {
|
|
request.Header.Set("Authorization", "Bearer "+authKey)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
return recorder
|
|
}
|
|
|
|
func assertUsageErrorCode(t *testing.T, recorder *httptest.ResponseRecorder, want string) {
|
|
t.Helper()
|
|
var envelope struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode error response: %v; body=%s", err, recorder.Body.String())
|
|
}
|
|
if recorder.Code != http.StatusBadRequest || envelope.Code != want {
|
|
t.Fatalf("error response = %d/%q, want 400/%q; body=%s", recorder.Code, envelope.Code, want, recorder.Body.String())
|
|
}
|
|
}
|