Files
tbphp 50ac12b003 feat(credentials): add compact usage and quota trends (#678)
* feat(credentials): add usage and subscription quota history

* fix(credentials): simplify subscription quota chart

* fix(credentials): restore compact usage trend

* fix(credentials): restore existing window usage

* fix(credentials): simplify compact trend charts

* fix(credentials): use view details icon and tooltip

* fix(credentials): clean quota history and chart leftovers

* feat(credentials): filter short quota windows and reuse trends

* fix(credentials): space quota trend points and connect gaps

* fix(subscription): sample quota history hourly and retain rebounds

* feat(credentials): overlay usage trend metrics

* fix(credentials): replace purple cost trend with teal

* feat(credentials): arrange linked trends in a compact grid

* fix(credentials): unify chart styling and scope tooltips

* fix(credentials): shorten quota trend heading

* fix(credentials): compact trends into a unified stats block

* fix(credentials): place three usage charts in one row

* fix(web): keep chart tooltips stable across data points

* fix(credentials): place lower chart tooltips below grid

* fix(credentials): preserve quota observations across transports

* fix(subscription): preserve quota history across transports

* fix(subscription): normalize quota observations before sampling

* fix(subscription): defer history until quota sources resolve
2026-09-17 21:12:12 +08:00

2504 lines
95 KiB
Go

package control
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/platform/config"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/protocol"
"gpt-load/internal/state"
"gpt-load/internal/storage/models"
)
func TestServerHomeAndSystemUpdateRoutesUseExactManagementContracts(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
module := NewServer(
&config.Config{AuthKey: "test-auth-key"},
fixture.service,
).HTTPModule()
type routeContract struct {
method string
path string
}
want := map[string]routeContract{
"control.home": {
method: http.MethodGet,
path: "/home",
},
"control.home.statistics": {
method: http.MethodGet,
path: "/home/statistics",
},
"control.home.subscription-accounts": {
method: http.MethodGet,
path: "/home/subscription-accounts",
},
"control.system.update": {
method: http.MethodGet,
path: "/system/update",
},
}
seen := make(map[string]int, len(want))
for _, route := range module.Routes {
contract, exists := want[route.Name]
if !exists {
continue
}
seen[route.Name]++
if len(route.Methods) != 1 ||
route.Methods[0] != contract.method ||
route.Path != contract.path {
t.Fatalf("route %q = %#v, want %#v", route.Name, route, contract)
}
}
for name := range want {
if seen[name] != 1 {
t.Fatalf("route %q occurrences = %d, want 1", name, seen[name])
}
}
}
func TestGroupCollectionHTTPRoutesDeclareStaticOptionsBeforeDynamicDetail(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
module := NewServer(
&config.Config{AuthKey: authTestKey},
fixture.service,
).HTTPModule()
type routeContract struct {
name string
path string
}
var got []routeContract
for _, route := range module.Routes {
if len(route.Methods) != 1 || route.Methods[0] != http.MethodGet ||
!strings.HasPrefix(route.Path, "/groups") {
continue
}
got = append(got, routeContract{name: route.Name, path: route.Path})
}
want := []routeContract{
{name: "control.groups.list", path: "/groups"},
{name: "control.groups.options", path: "/groups/options"},
{name: "control.groups.get", path: "/groups/:group_id"},
{name: "control.groups.settings.get", path: "/groups/:group_id/settings"},
{name: "control.groups.models.get", path: "/groups/:group_id/models"},
{name: "control.group-credentials.list", path: "/groups/:group_id/credentials"},
{name: "control.group-credentials.detail", path: "/groups/:group_id/credentials/:credential_id"},
{name: "control.group-credentials.quota-history", path: "/groups/:group_id/credentials/:credential_id/quota-history"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("GET group routes = %#v, want %#v", got, want)
}
}
func TestSystemInfoHTTPContract(t *testing.T) {
// 不标记 t.Parallel():本测试劫持了全局 logrus 输出/格式,与其他并行测试同时运行会互相覆盖断言。
initControlI18n(t)
fixture := newServiceFixture(t)
cfg := &config.Config{
AuthKey: "distinctive-system-info-auth-secret",
EncryptionKey: "distinctive-system-info-encryption-secret",
DatabaseDSN: "file:distinctive-system-info-secret-dsn",
DataDir: "./safe-data",
AuthKeyMetadata: config.SecretMetadata{
Source: config.SecretSourceKeyFile,
Path: "safe-data/auth.key",
},
EncryptionKeyMetadata: config.SecretMetadata{
Source: config.SecretSourceEnvironment,
},
}
engine := gin.New()
NewServer(cfg, fixture.service).RegisterRoutes(engine)
cfg.DataDir = "./mutated-data"
cfg.DatabaseDSN = "file:mutated-secret-dsn"
cfg.AuthKey = "mutated-auth-secret"
cfg.EncryptionKey = "mutated-encryption-secret"
cfg.AuthKeyMetadata.Path = "mutated-data/auth.key"
cfg.EncryptionKeyMetadata.Path = "mutated-data/encryption.key"
var logs bytes.Buffer
previousOutput := logrus.StandardLogger().Out
logrus.SetOutput(&logs)
t.Cleanup(func() { logrus.SetOutput(previousOutput) })
var responses bytes.Buffer
for _, test := range []struct {
name string
method string
auth string
wantStatus int
}{
{name: "get", method: http.MethodGet, auth: "Bearer distinctive-system-info-auth-secret", wantStatus: http.StatusOK},
{name: "unauthorized", method: http.MethodGet, wantStatus: http.StatusUnauthorized},
{name: "put method not allowed", method: http.MethodPut, auth: "Bearer distinctive-system-info-auth-secret", wantStatus: http.StatusMethodNotAllowed},
} {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(test.method, "/api/system/info", nil)
request.Header.Set("Accept-Language", "en-US")
if test.auth != "" {
request.Header.Set("Authorization", test.auth)
}
engine.ServeHTTP(recorder, request)
responses.Write(recorder.Body.Bytes())
if recorder.Code != test.wantStatus {
t.Fatalf("response = %d %s, want %d", recorder.Code, recorder.Body.String(), test.wantStatus)
}
if test.name != "get" {
return
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(envelope) != 3 {
t.Fatalf("envelope fields = %#v, want code/message/data", envelope)
}
if string(envelope["code"]) != "0" || string(envelope["message"]) != `"Success"` {
t.Fatalf("envelope = %s", recorder.Body.String())
}
var data map[string]any
if err := json.Unmarshal(envelope["data"], &data); err != nil {
t.Fatalf("decode data: %v", err)
}
if data["data_dir"] != "./safe-data" {
t.Fatalf("data_dir = %#v, want constructor-time value", data["data_dir"])
}
authKey, ok := data["auth_key"].(map[string]any)
if !ok || authKey["path"] != "safe-data/auth.key" {
t.Fatalf("auth_key = %#v, want constructor-time path", data["auth_key"])
}
})
}
for _, forbidden := range []string{
"distinctive-system-info-auth-secret",
"distinctive-system-info-encryption-secret",
"distinctive-system-info-secret-dsn",
"mutated-auth-secret",
"mutated-encryption-secret",
"mutated-secret-dsn",
"mutated-data",
} {
if strings.Contains(responses.String(), forbidden) {
t.Fatalf("HTTP response exposed %q: %s", forbidden, responses.String())
}
if strings.Contains(logs.String(), forbidden) {
t.Fatalf("HTTP logs exposed %q: %s", forbidden, logs.String())
}
}
}
func TestControlJSONBodyLimitBoundary(t *testing.T) {
t.Parallel()
if maxControlJSONBodyBytes != 32<<20 {
t.Fatalf("maxControlJSONBodyBytes = %d, want %d", maxControlJSONBodyBytes, 32<<20)
}
if apiErr := app_errors.ErrRequestTooLarge; apiErr.HTTPStatus != http.StatusRequestEntityTooLarge ||
apiErr.Code != "REQUEST_TOO_LARGE" || apiErr.Message != "Request body is too large" {
t.Fatalf("ErrRequestTooLarge = %#v, want 413 REQUEST_TOO_LARGE contract", apiErr)
}
const prefix = `{"name":"client"}`
for _, test := range []struct {
name string
size int64
wantErr bool
}{
{name: "exact limit", size: maxControlJSONBodyBytes},
{name: "one byte over limit", size: maxControlJSONBodyBytes + 1, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
body := prefix + strings.Repeat(" ", int(test.size)-len(prefix))
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = httptest.NewRequest(http.MethodPost, "/api/groups", strings.NewReader(body))
context.Request.ContentLength = -1
var target struct {
Name string `json:"name"`
}
err := bindStrictJSON(context, &target)
if !test.wantErr {
if err != nil {
t.Fatalf("bindStrictJSON() error = %v", err)
}
if target.Name != "client" {
t.Fatalf("target.Name = %q, want client", target.Name)
}
return
}
var maxBytesError *http.MaxBytesError
if !errors.As(err, &maxBytesError) {
t.Fatalf("bindStrictJSON() error = %T %v, want *http.MaxBytesError", err, err)
}
if got := mapControlJSONError(err); got != app_errors.ErrRequestTooLarge {
t.Fatalf("mapControlJSONError() = %#v, want ErrRequestTooLarge", got)
}
})
}
}
func TestControlMutationRejectsDuplicateJSONWithoutSideEffects(t *testing.T) {
t.Parallel()
initControlI18n(t)
for _, endpoint := range []struct {
name string
method string
path string
body string
}{
{
name: "group",
method: http.MethodPost,
path: "/api/groups",
body: `{"name":"first","name":"second",` +
`"upstream_url":"https://duplicate.example.com/v1",` +
`"protocols":["openai-completions"],"keys":"sk-duplicate"}`,
},
{
name: "access key",
method: http.MethodPost,
path: "/api/access-keys",
body: `{"name":"first","name":"second"}`,
},
{
name: "settings",
method: http.MethodPut,
path: "/api/settings",
body: `{"settings":{"request_timeout":900,"request_timeout":901}}`,
},
{
name: "route inspector",
method: http.MethodPost,
path: "/api/route/inspect",
body: `{"protocol":"openai-completions","protocol":"openai-completions",` +
`"external_model":"gpt-4o","access_key_id":1}`,
},
} {
t.Run(endpoint.name, func(t *testing.T) {
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).
RegisterRoutes(engine)
before := captureDuplicateJSONControlState(t, fixture)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(endpoint.method, endpoint.path, strings.NewReader(endpoint.body))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
if endpoint.path == "/api/groups" || endpoint.path == "/api/access-keys" {
setRequiredTestIdempotencyHeader(request)
}
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Errorf("response = %d %s, want 400", recorder.Code, recorder.Body.String())
} else {
var envelope struct {
Code string `json:"code"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Errorf("decode response: %v", err)
} else if envelope.Code != app_errors.ErrInvalidJSON.Code {
t.Errorf("response code = %q, want %q", envelope.Code, app_errors.ErrInvalidJSON.Code)
}
}
assertDuplicateJSONControlStateUnchanged(t, fixture, before)
})
}
}
type duplicateJSONControlState struct {
rowCounts [4]int64
snapshot *state.ConfigSnapshot
registry []state.CredentialRuntimeView
}
func captureDuplicateJSONControlState(
t *testing.T,
fixture serviceFixture,
) duplicateJSONControlState {
t.Helper()
var counts [4]int64
for index, model := range []any{
&models.Group{},
&models.AccessKey{},
&models.SystemSetting{},
&models.ModelPrice{},
} {
if err := fixture.db.Model(model).Count(&counts[index]).Error; err != nil {
t.Fatalf("count %T rows: %v", model, err)
}
}
return duplicateJSONControlState{
rowCounts: counts,
snapshot: fixture.manager.Current(),
registry: fixture.registry.Snapshot(),
}
}
func assertDuplicateJSONControlStateUnchanged(
t *testing.T,
fixture serviceFixture,
want duplicateJSONControlState,
) {
t.Helper()
got := captureDuplicateJSONControlState(t, fixture)
if got.rowCounts != want.rowCounts {
t.Errorf("database row counts = %v, want unchanged %v", got.rowCounts, want.rowCounts)
}
if got.snapshot != want.snapshot || got.snapshot.Revision != want.snapshot.Revision {
t.Errorf(
"snapshot pointer/revision = %p/%d, want unchanged %p/%d",
got.snapshot,
got.snapshot.Revision,
want.snapshot,
want.snapshot.Revision,
)
}
if !reflect.DeepEqual(got.registry, want.registry) {
t.Errorf("Registry runtime = %#v, want unchanged %#v", got.registry, want.registry)
}
}
type controlWhitespaceReader struct{}
func (controlWhitespaceReader) Read(buffer []byte) (int, error) {
for index := range buffer {
buffer[index] = ' '
}
return len(buffer), nil
}
func oversizedControlJSONBody(prefix string) io.Reader {
padding := maxControlJSONBodyBytes + 1 - int64(len(prefix))
return io.MultiReader(
strings.NewReader(prefix),
io.LimitReader(controlWhitespaceReader{}, padding),
)
}
type controlJSONBodyLimitState struct {
rowCounts [3]int64
snapshot *state.ConfigSnapshot
group models.Group
credential models.Credential
registryRuntime []state.CredentialRuntimeView
accessKey models.AccessKey
}
func captureControlJSONBodyLimitState(
t *testing.T,
fixture serviceFixture,
groupID uint,
accessKeyID uint,
) controlJSONBodyLimitState {
t.Helper()
var group models.Group
if err := fixture.db.First(&group, groupID).Error; err != nil {
t.Fatalf("load body-limit Group: %v", err)
}
var credential models.Credential
if err := fixture.db.Where("group_id = ?", groupID).
Order("id ASC").Take(&credential).Error; err != nil {
t.Fatalf("load body-limit Credential: %v", err)
}
return controlJSONBodyLimitState{
rowCounts: discoveryRowCounts(t, fixture.db),
snapshot: fixture.manager.Current(),
group: group,
credential: credential,
registryRuntime: fixture.registry.Snapshot(),
accessKey: loadAccessKeyRow(t, fixture.db, accessKeyID),
}
}
func assertControlJSONBodyLimitStateUnchanged(
t *testing.T,
fixture serviceFixture,
groupID uint,
accessKeyID uint,
want controlJSONBodyLimitState,
) {
t.Helper()
got := captureControlJSONBodyLimitState(t, fixture, groupID, accessKeyID)
if got.rowCounts != want.rowCounts {
t.Errorf("database row counts = %v, want unchanged %v", got.rowCounts, want.rowCounts)
}
if got.snapshot != want.snapshot {
t.Errorf(
"snapshot pointer/revision changed: got=%p/%d want=%p/%d",
got.snapshot,
got.snapshot.Revision,
want.snapshot,
want.snapshot.Revision,
)
}
if !reflect.DeepEqual(got.group, want.group) {
t.Errorf("persisted Group changed")
}
if !reflect.DeepEqual(got.credential, want.credential) {
t.Errorf("persisted Credential changed")
}
if !reflect.DeepEqual(got.registryRuntime, want.registryRuntime) {
t.Errorf(
"Registry runtime changed: got=%#v want=%#v",
got.registryRuntime,
want.registryRuntime,
)
}
if !reflect.DeepEqual(got.accessKey, want.accessKey) {
t.Errorf("persisted AccessKey changed")
}
}
func TestControlJSONBodyLimitAppliesToEveryJSONEndpoint(t *testing.T) {
t.Parallel()
initControlI18n(t)
for _, endpoint := range []struct {
name string
method string
path func(groupID, upstreamKeyID, accessKeyID uint) string
jsonPrefix string
}{
{
name: "create group", method: http.MethodPost,
path: func(uint, uint, uint) string { return "/api/groups" },
jsonPrefix: `{"name":"body-limit-group","upstream_url":"https://body-limit-create.example.com/v1",` +
`"protocols":["openai-completions"],"models":[],"keys":"sk-body-limit-create"}`,
},
{
name: "import group credentials", method: http.MethodPost,
path: func(groupID, _, _ uint) string {
return "/api/groups/" + strconv.FormatUint(uint64(groupID), 10) + "/credentials/import"
},
jsonPrefix: `{"credentials":"sk-body-limit-import"}`,
},
{
name: "update group settings", method: http.MethodPut,
path: func(groupID, _, _ uint) string {
return fmt.Sprintf("/api/groups/%d/settings", groupID)
},
jsonPrefix: `{"name":"body-limit-updated-group"}`,
},
{
name: "discover group models", method: http.MethodPost,
path: func(groupID, _, _ uint) string {
return "/api/groups/" + strconv.FormatUint(uint64(groupID), 10) + "/models/discover"
},
jsonPrefix: `{}`,
},
{
name: "save group models", method: http.MethodPut,
path: func(groupID, _, _ uint) string {
return "/api/groups/" + strconv.FormatUint(uint64(groupID), 10) + "/models"
},
jsonPrefix: `{"models":[]}`,
},
{
name: "update credential", method: http.MethodPut,
path: func(groupID, upstreamKeyID, _ uint) string {
return fmt.Sprintf("/api/groups/%d/credentials/%d", groupID, upstreamKeyID)
},
jsonPrefix: `{"status":"disabled"}`,
},
{
name: "discover draft models", method: http.MethodPost,
path: func(uint, uint, uint) string { return "/api/models/discover" },
jsonPrefix: `{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://body-limit-discover.example.com/v1"},` +
`"credentials":"sk-body-limit-discover"}`,
},
{
name: "create access key", method: http.MethodPost,
path: func(uint, uint, uint) string { return "/api/access-keys" },
jsonPrefix: `{"name":"body-limit-created-access-key"}`,
},
{
name: "update access key", method: http.MethodPut,
path: func(_, _ uint, accessKeyID uint) string {
return "/api/access-keys/" + strconv.FormatUint(uint64(accessKeyID), 10)
},
jsonPrefix: `{"name":"body-limit-updated-access-key"}`,
},
{
name: "route inspector", method: http.MethodPost,
path: func(uint, uint, uint) string { return "/api/route/inspect" },
jsonPrefix: `{"protocol":"openai-completions","external_model":"gpt-4o","access_key_id":1}`,
},
{
name: "update settings", method: http.MethodPut,
path: func(uint, uint, uint) string { return "/api/settings" },
jsonPrefix: `{"settings":{"request_timeout":900}}`,
},
} {
t.Run(endpoint.method+" "+endpoint.name, func(t *testing.T) {
fixture := newServiceFixture(t)
groupID := createGroupForCredentialImport(t, fixture, "sk-body-limit-seed")
accessKey, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{
Name: "body-limit-seed-access-key",
})
if err != nil {
t.Fatalf("seed CreateAccessKey() error = %v", err)
}
discoveryCalls := 0
fixture.service.executor = newRecordingDiscoveryExecutor(&recordingDiscoveryExecutorTarget{
value: protocol.OpenAICompletions,
listFn: func(context.Context, string, string, state.HeaderRules) ([]string, error) {
discoveryCalls++
return []string{"body-limit-model"}, nil
},
})
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
before := captureControlJSONBodyLimitState(t, fixture, groupID, accessKey.ID)
path := endpoint.path(groupID, before.credential.ID, accessKey.ID)
request := httptest.NewRequest(endpoint.method, path, oversizedControlJSONBody(endpoint.jsonPrefix))
request.ContentLength = -1
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
setRequiredTestIdempotencyHeader(request)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusRequestEntityTooLarge {
t.Errorf("response = %d %s, want 413", recorder.Code, recorder.Body.String())
} else {
var envelope struct {
Code string `json:"code"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Errorf("decode response: %v", err)
} else {
if envelope.Code != app_errors.ErrRequestTooLarge.Code {
t.Errorf("code = %q, want %q", envelope.Code, app_errors.ErrRequestTooLarge.Code)
}
if len(envelope.Data) != 0 {
t.Errorf("data = %s, want omitted", envelope.Data)
}
}
}
assertControlJSONBodyLimitStateUnchanged(t, fixture, groupID, accessKey.ID, before)
if discoveryCalls != 0 {
t.Errorf("model discovery calls = %d, want 0", discoveryCalls)
}
})
}
}
func TestControlJSONBodyLimitContentLengthFastPathPreservesAuthenticationPriority(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
for _, auth := range []struct {
name string
header string
wantStatus int
wantCode string
}{
{name: "valid", header: "Bearer test-auth-key", wantStatus: http.StatusRequestEntityTooLarge, wantCode: "REQUEST_TOO_LARGE"},
{name: "missing", wantStatus: http.StatusUnauthorized, wantCode: "UNAUTHORIZED"},
{name: "invalid", header: "Bearer wrong-auth-key", wantStatus: http.StatusUnauthorized, wantCode: "UNAUTHORIZED"},
} {
t.Run(auth.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/groups", strings.NewReader("{}"))
request.ContentLength = maxControlJSONBodyBytes + 1
if auth.header != "" {
request.Header.Set("Authorization", auth.header)
}
setRequiredTestIdempotencyHeader(request)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != auth.wantStatus {
t.Fatalf("response = %d %s, want %d", recorder.Code, recorder.Body.String(), auth.wantStatus)
}
var envelope struct {
Code string `json:"code"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != auth.wantCode {
t.Fatalf("code = %q, want %q", envelope.Code, auth.wantCode)
}
})
}
}
func TestControlJSONBodyLimitLocalizes413(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
for _, test := range []struct {
language string
message string
}{
{language: "zh-CN", message: "请求体过大"},
{language: "en-US", message: "Request body is too large"},
{language: "ja-JP", message: "リクエストボディが大きすぎます"},
} {
t.Run(test.language, func(t *testing.T) {
const prefix = `{"name":"localized-limit","upstream_url":"https://localized-limit.example.com/v1",` +
`"protocols":["openai-completions"],"models":[],"keys":"sk-localized-limit"}`
request := httptest.NewRequest(http.MethodPost, "/api/groups", oversizedControlJSONBody(prefix))
request.ContentLength = -1
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Accept-Language", test.language)
setRequiredTestIdempotencyHeader(request)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("response = %d %s, want 413", recorder.Code, recorder.Body.String())
}
var envelope struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != app_errors.ErrRequestTooLarge.Code || envelope.Message != test.message {
t.Fatalf("envelope = %#v, want code %q message %q", envelope, app_errors.ErrRequestTooLarge.Code, test.message)
}
})
}
}
func TestManagementAuthRequiresConstantShapeBearerToken(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
tests := []struct {
name string
header string
wantStatus int
}{
{name: "missing", wantStatus: http.StatusUnauthorized},
{name: "empty bearer", header: "Bearer", wantStatus: http.StatusUnauthorized},
{name: "wrong different length", header: "Bearer x", wantStatus: http.StatusUnauthorized},
{name: "extra field", header: "Bearer test-auth-key extra", wantStatus: http.StatusUnauthorized},
{name: "wrong scheme", header: "Basic test-auth-key", wantStatus: http.StatusUnauthorized},
{name: "case insensitive", header: "bEaReR test-auth-key", wantStatus: http.StatusOK},
}
for index, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/groups", nil)
request.RemoteAddr = "192.0.2." + strconv.Itoa(index+1) + ":1234"
if test.header != "" {
request.Header.Set("Authorization", test.header)
}
engine.ServeHTTP(recorder, request)
if recorder.Code != test.wantStatus {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.wantStatus, recorder.Body.String())
}
if test.wantStatus == http.StatusUnauthorized {
var body struct {
Code string `json:"code"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil || body.Code != "UNAUTHORIZED" {
t.Fatalf("unauthorized body = %s, error=%v", recorder.Body.String(), err)
}
}
})
}
}
func TestManagementAuthLocalizesUnauthorized(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
for _, test := range []struct {
language string
message string
}{
{language: "en-US", message: "Invalid authorization key"},
{language: "zh-CN", message: "无效的授权密钥"},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/groups", nil)
request.Header.Set("Accept-Language", test.language)
engine.ServeHTTP(recorder, request)
if !strings.Contains(recorder.Body.String(), test.message) {
t.Fatalf("%s body = %s, want message %q", test.language, recorder.Body.String(), test.message)
}
}
}
func TestManagementAuthDoesNotLogSecretOrDigest(t *testing.T) {
// 不标记 t.Parallel():本测试劫持了全局 logrus 输出/格式,与其他并行测试同时运行会互相覆盖断言。
initControlI18n(t)
const authKey = "distinctive-control-auth-key"
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: authKey}, fixture.service).RegisterRoutes(engine)
var logs bytes.Buffer
previousOutput := logrus.StandardLogger().Out
logrus.SetOutput(&logs)
t.Cleanup(func() { logrus.SetOutput(previousOutput) })
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/groups", nil)
request.Header.Set("Authorization", "Bearer "+authKey+"-wrong")
engine.ServeHTTP(recorder, request)
digest := sha256.Sum256([]byte(authKey))
for _, forbidden := range []string{authKey, hex.EncodeToString(digest[:])} {
if strings.Contains(logs.String(), forbidden) {
t.Fatalf("auth logs expose %q: %s", forbidden, logs.String())
}
}
}
func TestGroupCreateHTTPReturnsNarrowSuccessAndConflictEnvelopes(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
request := httptest.NewRequest(http.MethodPost, "/api/groups", strings.NewReader(
`{"name":"primary","channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com/v1/"},"models":[{"id":"gpt-4o","alias":"public-gpt","alias_enabled":true}],"credentials":"sk-first"}`,
))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
setRequiredTestIdempotencyHeader(request)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("create status = %d, body=%s", recorder.Code, recorder.Body.String())
}
var success struct {
Code int `json:"code"`
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &success); err != nil {
t.Fatalf("decode success response: %v", err)
}
if success.Code != 0 || len(success.Data) != 4 {
t.Fatalf("success response = %#v", success)
}
for _, field := range []string{"group_id", "group_name", "credentials_added", "credentials_duplicated"} {
if success.Data[field] == nil {
t.Fatalf("success data lacks %q: %#v", field, success.Data)
}
}
for _, forbidden := range []string{`"models"`, `"config"`, `"credentials"`, `"keys"`, "sk-first"} {
if strings.Contains(recorder.Body.String(), forbidden) {
t.Fatalf("success response exposes %q: %s", forbidden, recorder.Body.String())
}
}
var groupID uint
var groupName string
if err := json.Unmarshal(success.Data["group_id"], &groupID); err != nil || groupID == 0 {
t.Fatalf("decode group_id: %v, value=%s", err, success.Data["group_id"])
}
if err := json.Unmarshal(success.Data["group_name"], &groupName); err != nil || groupName != "primary" {
t.Fatalf("decode group_name: %v, value=%s", err, success.Data["group_name"])
}
request = httptest.NewRequest(http.MethodPost, "/api/groups", strings.NewReader(
`{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":" HTTPS://API.example.com/v1 "},"models":[],"credentials":"sk-second"}`,
))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
setRequiredTestIdempotencyHeader(request)
recorder = httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict {
t.Fatalf("conflict status = %d, body=%s", recorder.Code, recorder.Body.String())
}
var conflict struct {
Code string `json:"code"`
Data map[string][]map[string]any `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &conflict); err != nil {
t.Fatalf("decode conflict response: %v", err)
}
if conflict.Code != "CHANNEL_TARGET_CONFLICT" || len(conflict.Data) != 1 {
t.Fatalf("conflict response = %#v", conflict)
}
groups := conflict.Data["groups"]
if len(groups) != 1 || len(groups[0]) != 2 || groups[0]["id"] != float64(groupID) ||
groups[0]["name"] != groupName {
t.Fatalf("conflict groups = %#v", groups)
}
}
func TestGroupCreateHTTPRejectsLegacyMissingAndMalformedContractsWithoutMutation(t *testing.T) {
t.Parallel()
initControlI18n(t)
tests := []struct {
name string
body string
code string
}{
{
name: "legacy config",
body: `{"upstream_url":"https://api.example.com","protocols":["openai-completions"],` +
`"models":[],"keys":"sk-secret","config":{}}`,
code: app_errors.ErrInvalidJSON.Code,
},
{
name: "omitted models",
body: `{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},` +
`"credentials":"sk-secret"}`,
code: app_errors.ErrValidation.Code,
},
{
name: "null models",
body: `{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},` +
`"models":null,"credentials":"sk-secret"}`,
code: app_errors.ErrValidation.Code,
},
{
name: "unknown model field",
body: `{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},` +
`"models":[{"id":"gpt-4o","unknown":true}],"credentials":"sk-secret"}`,
code: app_errors.ErrInvalidJSON.Code,
},
{
name: "malformed JSON",
body: `{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},` +
`"models":[],"credentials":"sk-secret"`,
code: app_errors.ErrInvalidJSON.Code,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
before := countCreateImportRows(t, fixture)
request := httptest.NewRequest(http.MethodPost, "/api/groups", strings.NewReader(test.body))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
setRequiredTestIdempotencyHeader(request)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest ||
!strings.Contains(recorder.Body.String(), `"code":"`+test.code+`"`) {
t.Fatalf("response = %d %s, want 400/%s", recorder.Code, recorder.Body.String(), test.code)
}
if strings.Contains(recorder.Body.String(), "sk-secret") {
t.Fatalf("response exposes plaintext key: %s", recorder.Body.String())
}
if after := countCreateImportRows(t, fixture); after != before {
t.Fatalf("rows changed from %#v to %#v", before, after)
}
})
}
}
func TestImportGroupCredentialsEndpointReturnsSuccessEnvelope(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
groupID := createGroupForCredentialImport(t, fixture, "sk-existing")
beforeSnapshot := fixture.manager.Current()
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/groups/"+strconv.FormatUint(uint64(groupID), 10)+"/credentials/import", strings.NewReader(
`{"credentials":"sk-existing\nsk-new\nsk-new"}`,
))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
setRequiredTestIdempotencyHeader(request)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("import status = %d, body=%s", recorder.Code, recorder.Body.String())
}
var envelope struct {
Code int `json:"code"`
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode import response: %v", err)
}
if envelope.Code != 0 || len(envelope.Data) != 3 {
t.Fatalf("success envelope = %#v", envelope)
}
for _, field := range []string{"group_id", "credentials_added", "credentials_duplicated"} {
if _, ok := envelope.Data[field]; !ok {
t.Fatalf("success data lacks %q: %#v", field, envelope.Data)
}
}
var result CredentialImportResult
data, err := json.Marshal(envelope.Data)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, &result); err != nil {
t.Fatal(err)
}
if result.GroupID != groupID || result.CredentialsAdded != 1 || result.CredentialsDuplicated != 2 {
t.Fatalf("result = %#v", result)
}
if fixture.manager.Current() != beforeSnapshot {
t.Fatal("endpoint import published Snapshot")
}
}
func TestImportGroupCredentialsEndpointRejectsUnknownFieldsAndInvalidGroupID(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
groupID := createGroupForCredentialImport(t, fixture, "sk-existing")
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
tests := []struct {
name string
groupID string
body string
}{
{name: "unknown field", groupID: strconv.FormatUint(uint64(groupID), 10), body: `{"credentials":"sk-new","name":"must-not-change"}`},
{name: "multiple JSON values", groupID: strconv.FormatUint(uint64(groupID), 10), body: `{"credentials":"sk-new"} {"credentials":"sk-other"}`},
{name: "zero group ID", groupID: "0", body: `{"credentials":"sk-new"}`},
{name: "non-numeric group ID", groupID: "not-a-number", body: `{"credentials":"sk-new"}`},
{name: "overflowing group ID", groupID: "18446744073709551616", body: `{"credentials":"sk-new"}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
beforeSnapshot := fixture.manager.Current()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/groups/"+test.groupID+"/credentials/import", strings.NewReader(test.body))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
setRequiredTestIdempotencyHeader(request)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body=%s, want 400", recorder.Code, recorder.Body.String())
}
if fixture.manager.Current() != beforeSnapshot {
t.Fatal("invalid endpoint request published Snapshot")
}
assertImportedCredentialState(t, fixture, groupID, 1)
})
}
}
func TestImportGroupCredentialsEndpointReturnsGroupNotFound(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/groups/999/credentials/import", strings.NewReader(`{"credentials":"sk-new"}`))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept-Language", "zh-CN")
setRequiredTestIdempotencyHeader(request)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("status = %d, body=%s, want 404", recorder.Code, recorder.Body.String())
}
var envelope struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if envelope.Code != "NOT_FOUND" || envelope.Message != "分组不存在" {
t.Fatalf("not-found envelope = %#v", envelope)
}
}
func createGroupForCredentialImport(t *testing.T, fixture serviceFixture, credentials string) uint {
t.Helper()
result, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
Name: stringPointer("credential-import-" + strconv.FormatUint(testIdempotencySequence.Add(1), 10)),
ChannelID: channel.OpenAI, Params: json.RawMessage(`{}`),
Models: optionalGroupModels{Set: true}, Credentials: credentials, ConnectionType: "api_key",
})
if err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
return result.GroupID
}
func assertImportedCredentialState(t *testing.T, fixture serviceFixture, groupID uint, want int) {
t.Helper()
var rows []models.Credential
if err := fixture.db.Where("group_id = ?", groupID).Order("id ASC").Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != want {
t.Fatalf("persisted credentials = %d, want %d", len(rows), want)
}
for _, row := range rows {
if value, ok := fixture.registry.EncryptedCredentialData(row.ID); !ok || value != row.Data {
t.Fatalf("Registry credential %d = %q, %t, want %q", row.ID, value, ok, row.Data)
}
}
}
func TestLegacyImportRouteIsNotRegistered(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
for _, route := range engine.Routes() {
if route.Method == http.MethodPost && route.Path == "/api/import" {
t.Fatalf("legacy route remains registered: %#v", route)
}
}
}
func TestManagementWritesRejectUnknownFieldsAndMultipleJSONValues(t *testing.T) {
t.Parallel()
initControlI18n(t)
t.Run("group create rejects unknown top-level field", func(t *testing.T) {
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
beforeRevision := fixture.manager.Current().Revision
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/groups", strings.NewReader(
`{"upstream_url":"https://api.example.com","protocols":["openai-completions"],"keys":"sk-test","unexpected":true}`,
))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
setRequiredTestIdempotencyHeader(request)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("POST /api/groups = %d %s, want 400", recorder.Code, recorder.Body.String())
}
assertGroupCount(t, fixture.db, 0)
if got := fixture.manager.Current().Revision; got != beforeRevision {
t.Fatalf("snapshot revision = %d, want unchanged %d", got, beforeRevision)
}
})
t.Run("access key rejects unknown nested filter field", func(t *testing.T) {
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
beforeRevision := fixture.manager.Current().Revision
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/access-keys", strings.NewReader(
`{"name":"client","filters":{"gropus":[1]}}`,
))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
setRequiredTestIdempotencyHeader(request)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("POST /api/access-keys = %d %s, want 400", recorder.Code, recorder.Body.String())
}
var count int64
if err := fixture.db.Table("access_keys").Count(&count).Error; err != nil {
t.Fatalf("count access keys: %v", err)
}
if count != 0 {
t.Fatalf("access key count = %d, want 0", count)
}
if got := fixture.manager.Current().Revision; got != beforeRevision {
t.Fatalf("snapshot revision = %d, want unchanged %d", got, beforeRevision)
}
})
t.Run("access key update rejects a second JSON value", func(t *testing.T) {
fixture := newServiceFixture(t)
fixture.service.random = bytes.NewReader(make([]byte, 16))
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "original"})
if err != nil {
t.Fatalf("CreateAccessKey() error = %v", err)
}
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
beforeRevision := fixture.manager.Current().Revision
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPut,
"/api/access-keys/"+strconv.FormatUint(uint64(created.ID), 10),
strings.NewReader(`{"name":"changed"}{"status":"disabled"}`),
)
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("PUT /api/access-keys/:id = %d %s, want 400", recorder.Code, recorder.Body.String())
}
row := loadAccessKeyRow(t, fixture.db, created.ID)
if row.Name != "original" || row.Status != string(state.AccessKeyStatusActive) {
t.Fatalf("access key row = %#v, want original active row", row)
}
if got := fixture.manager.Current().Revision; got != beforeRevision {
t.Fatalf("snapshot revision = %d, want unchanged %d", got, beforeRevision)
}
})
}
func TestUpdateGroupSettingsEndpointRejectsStrictInvalidBodies(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
groupID := createGroupForCredentialImport(t, fixture, "sk-update-http")
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
for _, test := range []struct {
name string
body string
code string
}{
{name: "empty object", body: `{}`, code: app_errors.ErrBadRequest.Code},
{name: "retired confirmation", body: `{"confirm_upstream_change":true}`, code: app_errors.ErrInvalidJSON.Code},
{name: "unknown field", body: `{"name":"changed","unknown":true}`, code: app_errors.ErrInvalidJSON.Code},
{name: "multiple JSON values", body: `{"name":"changed"} {"enabled":false}`, code: app_errors.ErrInvalidJSON.Code},
{name: "null name", body: `{"name":null}`, code: app_errors.ErrValidation.Code},
{name: "negative weight", body: `{"weight_manual":-1}`, code: app_errors.ErrValidation.Code},
{name: "retired protocols", body: `{"protocols":[]}`, code: app_errors.ErrInvalidJSON.Code},
{name: "invalid overrides", body: `{"overrides":{"first_byte_timeout":-1}}`, code: app_errors.ErrValidation.Code},
{name: "retired retry count", body: `{"overrides":{"retry_count":4}}`, code: app_errors.ErrValidation.Code},
{name: "retired zero retry count", body: `{"overrides":{"retry_count":0}}`, code: app_errors.ErrValidation.Code},
{name: "retired null retry count", body: `{"overrides":{"retry_count":null}}`, code: app_errors.ErrValidation.Code},
{name: "parameter override negative zero", body: `{"overrides":{"parameter_overrides":[{"set":{"value":-0}}]}}`, code: app_errors.ErrValidation.Code},
{name: "parameter override negative decimal zero", body: `{"overrides":{"parameter_overrides":[{"set":{"value":-0.0}}]}}`, code: app_errors.ErrValidation.Code},
{name: "parameter override negative exponent zero", body: `{"overrides":{"parameter_overrides":[{"set":{"value":-0e3}}]}}`, code: app_errors.ErrValidation.Code},
} {
t.Run(test.name, func(t *testing.T) {
before := fixture.manager.Current().Revision
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPut,
"/api/groups/"+strconv.FormatUint(uint64(groupID), 10)+"/settings",
strings.NewReader(test.body),
)
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("response status = %d, want 400", recorder.Code)
}
var envelope struct {
Code string `json:"code"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if recorder.Code != http.StatusBadRequest || envelope.Code != test.code {
t.Fatalf("response = %d %#v, want 400 %q", recorder.Code, envelope, test.code)
}
if fixture.manager.Current().Revision != before {
t.Fatal("invalid update published a Snapshot")
}
})
}
}
func TestUpdateGroupSettingsEndpointRejectsTopLevelNullWithoutMutation(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
groupID := createGroupForCredentialImport(t, fixture, "sk-update-null")
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
var beforeGroup models.Group
if err := fixture.db.First(&beforeGroup, groupID).Error; err != nil {
t.Fatal(err)
}
var beforeCredentials []models.Credential
if err := fixture.db.Where("group_id = ?", groupID).Order("id ASC").Find(&beforeCredentials).Error; err != nil {
t.Fatal(err)
}
beforeRevision := fixture.manager.Current().Revision
beforeRegistry := fixture.registry.CollectCredentialCandidates([]uint{groupID}, nil, time.Time{})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPut,
"/api/groups/"+strconv.FormatUint(uint64(groupID), 10)+"/settings",
strings.NewReader(`null`),
)
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(recorder, request)
assertUpdateGroupErrorResponse(t, recorder, http.StatusBadRequest, app_errors.ErrInvalidJSON.Code, "请求错误")
var afterGroup models.Group
if err := fixture.db.First(&afterGroup, groupID).Error; err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(afterGroup, beforeGroup) {
t.Fatalf("persisted group changed: got=%#v want=%#v", afterGroup, beforeGroup)
}
var afterCredentials []models.Credential
if err := fixture.db.Where("group_id = ?", groupID).Order("id ASC").Find(&afterCredentials).Error; err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(afterCredentials, beforeCredentials) {
t.Fatalf("persisted credentials changed: got=%#v want=%#v", afterCredentials, beforeCredentials)
}
if got := fixture.manager.Current().Revision; got != beforeRevision {
t.Fatalf("snapshot revision = %d, want %d", got, beforeRevision)
}
if got := fixture.registry.CollectCredentialCandidates([]uint{groupID}, nil, time.Time{}); !reflect.DeepEqual(got, beforeRegistry) {
t.Fatalf("Registry candidates changed: got=%#v want=%#v", got, beforeRegistry)
}
}
func TestUpdateGroupSettingsEndpointAllowsURLReuseAndPreservesI18nAndAuth(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
first, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
Name: stringPointer("first-group"), ChannelID: channel.OpenAICompatible,
Params: json.RawMessage(`{"base_url":"https://first.example.com/v1"}`),
Models: optionalGroupModels{Set: true}, Credentials: "sk-update-first", ConnectionType: "api_key",
})
if err != nil {
t.Fatal(err)
}
firstID := first.GroupID
second, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
Name: stringPointer("other-group"), ChannelID: channel.OpenAICompatible,
Params: json.RawMessage(`{"base_url":"https://conflict.example.com/v1"}`),
Models: optionalGroupModels{Set: true, Values: []GroupModel{}}, Credentials: "sk-update-second", ConnectionType: "api_key",
})
if err != nil {
t.Fatal(err)
}
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
path := "/api/groups/" + strconv.FormatUint(uint64(firstID), 10) + "/settings"
request := httptest.NewRequest(http.MethodPut, path, strings.NewReader(
`{"params":{"base_url":"https://unique.example.com/v1"}}`,
))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept-Language", "ja-JP")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("unconfirmed unique URL update = %d %s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPut, path, strings.NewReader(
`{"params":{"base_url":"https://conflict.example.com/v1/"}}`,
))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
recorder = httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("duplicate URL update = %d %s", recorder.Code, recorder.Body.String())
}
var duplicateSuccess struct {
Code int `json:"code"`
Data GroupSettingsResponse `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &duplicateSuccess); err != nil {
t.Fatal(err)
}
if duplicateSuccess.Code != 0 || duplicateSuccess.Data.ChannelID != channel.OpenAICompatible ||
string(duplicateSuccess.Data.Params) != `{"base_url":"https://conflict.example.com/v1"}` {
t.Fatalf("duplicate URL success envelope = %#v", duplicateSuccess)
}
request = httptest.NewRequest(http.MethodPut, path, strings.NewReader(
`{"name":"other-group"}`,
))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
recorder = httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
assertUpdateGroupErrorResponse(t, recorder, http.StatusConflict, app_errors.ErrDuplicateResource.Code, "分组名称已存在")
request = httptest.NewRequest(http.MethodPut, path, strings.NewReader(
`{"params":{"base_url":" HTTPS://UNIQUE.example.com/v1/ "},"enabled":false}`,
))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept-Language", "ja-JP")
recorder = httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("success response = %d %s", recorder.Code, recorder.Body.String())
}
var success struct {
Code int `json:"code"`
Message string `json:"message"`
Data GroupSettingsResponse `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &success); err != nil {
t.Fatal(err)
}
if success.Code != 0 || success.Message != "成功" || success.Data.Enabled ||
string(success.Data.Params) != `{"base_url":"https://unique.example.com/v1"}` {
t.Fatalf("success envelope = %#v", success)
}
unauthorized := httptest.NewRecorder()
engine.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodPut, path, strings.NewReader(`{"enabled":true}`)))
if unauthorized.Code != http.StatusUnauthorized {
t.Fatalf("unauthorized response = %d %s", unauthorized.Code, unauthorized.Body.String())
}
if second.GroupID == 0 {
t.Fatal("second Group was not created")
}
}
func TestUpdateGroupSettingsEndpointRejectsOversizedJSON(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
groupID := createGroupForCredentialImport(t, fixture, "sk-update-limit")
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
before := fixture.manager.Current().Revision
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPut,
"/api/groups/"+strconv.FormatUint(uint64(groupID), 10)+"/settings",
oversizedControlJSONBody(`{"name":"changed"}`),
)
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
request.ContentLength = -1
engine.ServeHTTP(recorder, request)
assertUpdateGroupErrorResponse(t, recorder, http.StatusRequestEntityTooLarge, app_errors.ErrRequestTooLarge.Code, "请求体过大")
if fixture.manager.Current().Revision != before {
t.Fatal("oversized update published a Snapshot")
}
}
func TestUpdateGroupModelsEndpointRejectsStrictInvalidBodiesWithoutMutation(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
ChannelID: channel.OpenAICompatible,
Params: json.RawMessage(`{"base_url":"https://model-save-http-invalid.example.com/v1"}`),
Models: optionalGroupModels{
Set: true,
Values: []GroupModel{{ID: "provider-old", Alias: "old-public", AliasEnabled: true}},
},
Credentials: "sk-model-save-http-invalid", ConnectionType: "api_key",
})
if err != nil {
t.Fatal(err)
}
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
for _, test := range []struct {
name string
body string
code string
}{
{name: "missing models", body: `{}`, code: app_errors.ErrValidation.Code},
{name: "null models", body: `{"models":null}`, code: app_errors.ErrValidation.Code},
{name: "models object", body: `{"models":{}}`, code: app_errors.ErrInvalidJSON.Code},
{name: "unknown top-level field", body: `{"models":[],"unknown":true}`, code: app_errors.ErrInvalidJSON.Code},
{name: "unknown nested field", body: `{"models":[{"id":"provider","unknown":true}]}`, code: app_errors.ErrInvalidJSON.Code},
{name: "blank upstream model ID", body: `{"models":[{"id":" "}]}`, code: app_errors.ErrValidation.Code},
{name: "multiple JSON values", body: `{"models":[]} {"models":[]}`, code: app_errors.ErrInvalidJSON.Code},
{name: "top-level null", body: `null`, code: app_errors.ErrInvalidJSON.Code},
} {
t.Run(test.name, func(t *testing.T) {
beforeRevision := fixture.manager.Current().Revision
beforeModels := loadCreatedGroupModels(t, fixture, created.GroupID)
recorder := serveRawGroupModelsUpdateRequest(
t,
engine,
"test-auth-key",
"en-US",
strconv.FormatUint(uint64(created.GroupID), 10),
test.body,
)
var envelope struct {
Code string `json:"code"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if recorder.Code != http.StatusBadRequest || envelope.Code != test.code {
t.Fatalf("response = %d %#v, want 400 %q", recorder.Code, envelope, test.code)
}
if fixture.manager.Current().Revision != beforeRevision {
t.Fatal("invalid models save published a Snapshot")
}
if got := loadCreatedGroupModels(t, fixture, created.GroupID); !reflect.DeepEqual(got, beforeModels) {
t.Fatalf("invalid models save changed persistence: got=%#v want=%#v", got, beforeModels)
}
})
}
}
func TestGroupModelsHTTPReturnsStructuredConflictWithoutMutation(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
groupID := createGroupForCredentialImport(t, fixture, "sk-model-conflict-http")
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
beforeRevision := fixture.manager.Current().Revision
beforeModels := loadCreatedGroupModels(t, fixture, groupID)
recorder := serveRawGroupModelsUpdateRequest(
t,
engine,
"test-auth-key",
"en-US",
strconv.FormatUint(uint64(groupID), 10),
`{"models":[{"id":"a","alias":"discarded","alias_enabled":false},{"id":"b","alias":"a","alias_enabled":true}]}`,
)
var envelope struct {
Code string `json:"code"`
Data ModelNameConflictData `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
wantConflicts := []ModelNameConflict{{ClientModel: "a", Indexes: []int{0, 1}}}
if recorder.Code != http.StatusConflict || envelope.Code != app_errors.ErrModelNameConflict.Code ||
!reflect.DeepEqual(envelope.Data.Conflicts, wantConflicts) {
t.Fatalf("response = %d %#v, want 409 %#v", recorder.Code, envelope, wantConflicts)
}
if fixture.manager.Current().Revision != beforeRevision {
t.Fatal("model conflict published a Snapshot")
}
if got := loadCreatedGroupModels(t, fixture, groupID); !reflect.DeepEqual(got, beforeModels) {
t.Fatalf("model conflict changed persistence: got=%#v want=%#v", got, beforeModels)
}
}
func TestGroupModelsHTTPRejectsMissingAliasEnabledWithoutMutation(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
mustEnsureInitialPrices(t, fixture)
groupID := createGroupForCredentialImport(t, fixture, "sk-model-alias-default")
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
beforeRevision := fixture.manager.Current().Revision
beforeModels := loadCreatedGroupModels(t, fixture, groupID)
recorder := serveRawGroupModelsUpdateRequest(
t,
engine,
"test-auth-key",
"en-US",
strconv.FormatUint(uint64(groupID), 10),
`{"models":[{"id":"gpt-4o","alias":"legacy-name"}]}`,
)
if recorder.Code != http.StatusBadRequest ||
!strings.Contains(recorder.Body.String(), `"code":"VALIDATION_FAILED"`) {
t.Fatalf("response = %d %s, want 400 VALIDATION_FAILED", recorder.Code, recorder.Body.String())
}
if fixture.manager.Current().Revision != beforeRevision {
t.Fatal("missing alias_enabled published a Snapshot")
}
if got := loadCreatedGroupModels(t, fixture, groupID); !reflect.DeepEqual(got, beforeModels) {
t.Fatalf("missing alias_enabled changed persistence: got=%#v want=%#v", got, beforeModels)
}
}
func TestUpdateGroupModelsEndpointIDsAuthNotFoundAndSuccessDTO(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
mustEnsureInitialPrices(t, fixture)
created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
ChannelID: channel.OpenAICompatible,
Params: json.RawMessage(`{"base_url":"https://model-save-http.example.com/v1"}`),
Models: optionalGroupModels{
Set: true,
Values: []GroupModel{{ID: "provider-old", Alias: "old-public", AliasEnabled: true}},
},
Credentials: "sk-model-save-http", ConnectionType: "api_key",
})
if err != nil {
t.Fatal(err)
}
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
body := `{"models":[{"id":"provider-new","alias":"new-public","alias_enabled":true}]}`
for _, rawID := range []string{"0", "-1", "not-a-number", "18446744073709551616"} {
beforeRevision := fixture.manager.Current().Revision
recorder := serveRawGroupModelsUpdateRequest(t, engine, "test-auth-key", "en-US", rawID, body)
if recorder.Code != http.StatusBadRequest ||
!strings.Contains(recorder.Body.String(), `"code":"BAD_REQUEST"`) {
t.Fatalf("Group ID %q response = %d %s", rawID, recorder.Code, recorder.Body.String())
}
if fixture.manager.Current().Revision != beforeRevision {
t.Fatalf("invalid Group ID %q published a Snapshot", rawID)
}
}
beforeRevision := fixture.manager.Current().Revision
unauthorized := serveRawGroupModelsUpdateRequest(
t,
engine,
"",
"en-US",
strconv.FormatUint(uint64(created.GroupID), 10),
body,
)
if unauthorized.Code != http.StatusUnauthorized ||
!strings.Contains(unauthorized.Body.String(), `"code":"UNAUTHORIZED"`) {
t.Fatalf("unauthorized response = %d %s", unauthorized.Code, unauthorized.Body.String())
}
if fixture.manager.Current().Revision != beforeRevision {
t.Fatal("unauthorized models save published a Snapshot")
}
missing := serveRawGroupModelsUpdateRequest(t, engine, "test-auth-key", "zh-CN", "999", body)
if missing.Code != http.StatusNotFound ||
!strings.Contains(missing.Body.String(), `"code":"NOT_FOUND"`) ||
!strings.Contains(missing.Body.String(), "分组不存在") {
t.Fatalf("missing Group response = %d %s", missing.Code, missing.Body.String())
}
success := serveRawGroupModelsUpdateRequest(
t,
engine,
"test-auth-key",
"ja-JP",
strconv.FormatUint(uint64(created.GroupID), 10),
body,
)
if success.Code != http.StatusOK {
t.Fatalf("success response = %d %s", success.Code, success.Body.String())
}
var envelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(success.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if envelope.Code != 0 || envelope.Message != "成功" {
t.Fatalf("success envelope = %#v", envelope)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(envelope.Data, &fields); err != nil {
t.Fatal(err)
}
if _, wrapped := fields["group"]; wrapped {
t.Fatalf("models save returned legacy detail wrapper: %s", envelope.Data)
}
if _, wrapped := fields["model_rediscovery_recommended"]; wrapped {
t.Fatalf("models save returned discovery diff metadata: %s", envelope.Data)
}
var result GroupModelsResponse
if err := json.Unmarshal(envelope.Data, &result); err != nil {
t.Fatal(err)
}
want := GroupModelsResponse{
Items: []GroupModelResponse{{
ID: "provider-new", Alias: "new-public", AliasEnabled: true, ClientModel: "new-public", PricingStatus: PricingStatusPending,
}},
Total: 1,
Pending: 1,
}
if !reflect.DeepEqual(result, want) {
t.Fatalf("success models response = %#v, want %#v", result, want)
}
}
func serveRawGroupModelsUpdateRequest(
t *testing.T,
engine *gin.Engine,
authKey, language, groupID, body string,
) *httptest.ResponseRecorder {
t.Helper()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPut,
"/api/groups/"+groupID+"/models",
strings.NewReader(body),
)
if authKey != "" {
request.Header.Set("Authorization", "Bearer "+authKey)
}
request.Header.Set("Accept-Language", language)
request.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(recorder, request)
return recorder
}
func assertUpdateGroupErrorResponse(
t *testing.T,
recorder *httptest.ResponseRecorder,
wantStatus int,
wantCode string,
wantMessage string,
) {
t.Helper()
var envelope struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if recorder.Code != wantStatus || envelope.Code != wantCode || envelope.Message != wantMessage {
t.Fatalf("response = %d %#v, want %d %q %q", recorder.Code, envelope, wantStatus, wantCode, wantMessage)
}
}
func TestUpdateAccessKeyRoutesParseIDsAndPreservePointerSemantics(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
fixture.service.random = bytes.NewReader(make([]byte, 16))
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "route-key"})
if err != nil {
t.Fatalf("CreateAccessKey() error = %v", err)
}
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
request := httptest.NewRequest(http.MethodPut, "/api/access-keys/"+strconv.FormatUint(uint64(created.ID), 10), strings.NewReader(`{"status":"disabled"}`))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK ||
strings.Contains(recorder.Body.String(), created.Key) ||
!strings.Contains(recorder.Body.String(), created.MaskedKey) {
t.Fatalf("PUT access key = %d %s", recorder.Code, recorder.Body.String())
}
row := loadAccessKeyRow(t, fixture.db, created.ID)
if row.Name != "route-key" || row.Status != string(state.AccessKeyStatusDisabled) {
t.Fatalf("PUT row = %#v", row)
}
for _, path := range []string{
"/api/access-keys/0",
"/api/access-keys/-1",
"/api/access-keys/not-a-number",
"/api/access-keys/18446744073709551616",
} {
request = httptest.NewRequest(http.MethodPut, path, strings.NewReader(`{"name":"ignored"}`))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
recorder = httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("PUT %s = %d %s, want 400", path, recorder.Code, recorder.Body.String())
}
}
request = httptest.NewRequest(http.MethodPut, "/api/access-keys/"+strconv.FormatUint(uint64(created.ID), 10), strings.NewReader(`{}`))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
recorder = httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("empty PUT = %d %s, want 400", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodDelete, "/api/access-keys/"+strconv.FormatUint(uint64(created.ID), 10), nil)
request.Header.Set("Authorization", "Bearer test-auth-key")
recorder = httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("DELETE access key = %d %s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodDelete, "/api/access-keys/"+strconv.FormatUint(uint64(created.ID), 10), nil)
request.Header.Set("Authorization", "Bearer test-auth-key")
recorder = httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("repeated DELETE = %d %s, want 404", recorder.Code, recorder.Body.String())
}
}
func TestModelDiscoveryHTTPContract(t *testing.T) {
t.Parallel()
initControlI18n(t)
const authKey = "test-auth-key"
newServer := func(value *recordingDiscoveryExecutorTarget) (*Service, *gin.Engine) {
t.Helper()
fixture := newServiceFixture(t)
if value == nil {
fixture.service.executor = nil
} else {
fixture.service.executor = newRecordingDiscoveryExecutor(value)
}
engine := gin.New()
NewServer(&config.Config{AuthKey: authKey}, fixture.service).RegisterRoutes(engine)
return fixture.service, engine
}
t.Run("success preserves order", func(t *testing.T) {
_, engine := newServer(&recordingDiscoveryExecutorTarget{
value: protocol.OpenAICompletions,
listFn: func(
context.Context,
string,
string,
state.HeaderRules,
) ([]string, error) {
return []string{"z-model", "a-model"}, nil
},
})
recorder := serveDiscoveryRequest(t, engine, authKey,
`{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},`+
`"credentials":"sk-upstream"}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
}
var response struct {
Code int `json:"code"`
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Code != 0 || len(response.Data) != 1 {
t.Fatalf("response = %#v", response)
}
var models []ModelCandidate
if err := json.Unmarshal(response.Data["models"], &models); err != nil ||
!reflect.DeepEqual(models, []ModelCandidate{
{ID: "z-model", Name: "z-model", Sources: []string{"live"}, PricingStatus: PricingStatusPending},
{ID: "a-model", Name: "a-model", Sources: []string{"live"}, PricingStatus: PricingStatusPending},
}) {
t.Fatalf("models = %#v, error=%v", models, err)
}
})
t.Run("empty list remains an array", func(t *testing.T) {
_, engine := newServer(&recordingDiscoveryExecutorTarget{
value: protocol.OpenAICompletions,
listFn: func(
context.Context,
string,
string,
state.HeaderRules,
) ([]string, error) {
return nil, nil
},
})
recorder := serveDiscoveryRequest(t, engine, authKey,
`{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},`+
`"credentials":"sk-upstream"}`,
)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"models":[]`) {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
})
t.Run("authentication is inherited", func(t *testing.T) {
_, engine := newServer(&recordingDiscoveryExecutorTarget{
value: protocol.OpenAICompletions,
listFn: func(context.Context, string, string, state.HeaderRules) ([]string, error) {
t.Fatal("ListModels called without valid management authentication")
return nil, nil
},
})
for _, token := range []string{"", "wrong-key"} {
recorder := serveDiscoveryRequest(t, engine, token,
`{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},`+
`"credentials":"sk-upstream"}`,
)
if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Body.String(), `"code":"UNAUTHORIZED"`) {
t.Fatalf("auth %q response = %d %s", token, recorder.Code, recorder.Body.String())
}
}
})
t.Run("strict JSON", func(t *testing.T) {
_, engine := newServer(&recordingDiscoveryExecutorTarget{
value: protocol.OpenAICompletions,
listFn: func(context.Context, string, string, state.HeaderRules) ([]string, error) {
t.Fatal("ListModels called for invalid JSON")
return nil, nil
},
})
for _, payload := range []string{
`{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},"credentials":"sk-upstream","config":{}}`,
`{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},"credentials":"sk-upstream","unknown":true}`,
`{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},"credentials":"sk-upstream"}{}`,
`{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},"credentials":"sk-upstream"`,
`{"upstream_url":"https://api.example.com","protocols":["openai-completions"],"keys":"sk-upstream"}`,
} {
recorder := serveDiscoveryRequest(t, engine, authKey, payload)
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), `"code":"INVALID_JSON"`) {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
}
})
t.Run("validation and invariant errors are fixed", func(t *testing.T) {
_, engine := newServer(nil)
for _, test := range []struct {
payload string
status int
code string
}{
{payload: `{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"/relative"},"credentials":"secret-key"}`, status: http.StatusBadRequest, code: "VALIDATION_FAILED"},
{payload: `{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com?token=query-secret"},"credentials":"secret-key"}`, status: http.StatusBadRequest, code: "VALIDATION_FAILED"},
} {
recorder := serveDiscoveryRequest(t, engine, authKey, test.payload)
if recorder.Code != test.status || !strings.Contains(recorder.Body.String(), `"code":"`+test.code+`"`) {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
for _, secret := range []string{"secret-key", "query-secret", "api.example.com"} {
if strings.Contains(recorder.Body.String(), secret) {
t.Fatalf("response exposes %q: %s", secret, recorder.Body.String())
}
}
}
})
t.Run("upstream failures map to localized bad gateway", func(t *testing.T) {
service, engine := newServer(&recordingDiscoveryExecutorTarget{
value: protocol.OpenAICompletions,
listFn: func(context.Context, string, string, state.HeaderRules) ([]string, error) {
return nil, fmt.Errorf("raw upstream failure with secret-body")
},
})
service.modelDiscoveryTimeout = 20 * time.Millisecond
for _, test := range []struct {
language string
message string
}{
{language: "en-US", message: "Upstream service error"},
{language: "zh-CN", message: "上游服务错误"},
} {
recorder := serveDiscoveryRequestWithLanguage(t, engine, authKey,
`{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},`+
`"credentials":"secret-key"}`,
test.language,
)
if recorder.Code != http.StatusBadGateway ||
!strings.Contains(recorder.Body.String(), `"code":"BAD_GATEWAY"`) ||
!strings.Contains(recorder.Body.String(), test.message) {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
for _, forbidden := range []string{"secret-key", "query-secret", "secret-body", "raw upstream failure"} {
if strings.Contains(recorder.Body.String(), forbidden) {
t.Fatalf("response exposes %q: %s", forbidden, recorder.Body.String())
}
}
}
})
t.Run("timeout maps to bad gateway", func(t *testing.T) {
service, engine := newServer(&recordingDiscoveryExecutorTarget{
value: protocol.OpenAICompletions,
listFn: func(ctx context.Context, _ string, _ string, _ state.HeaderRules) ([]string, error) {
<-ctx.Done()
return nil, ctx.Err()
},
})
service.modelDiscoveryTimeout = 20 * time.Millisecond
recorder := serveDiscoveryRequest(t, engine, authKey,
`{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},`+
`"credentials":"sk-upstream"}`,
)
if recorder.Code != http.StatusBadGateway || !strings.Contains(recorder.Body.String(), `"code":"BAD_GATEWAY"`) {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
})
t.Run("broken upstream JSON maps to bad gateway", func(t *testing.T) {
fixture := newServiceFixture(t)
fixture.service.executor = scriptedDiscoveryExecutor{execute: func(
context.Context,
execution.AttemptSpec,
) execution.AttemptResult {
return execution.AttemptResult{
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
StatusCode: http.StatusOK, Header: http.Header{}, Body: []byte(`{"data":[`),
}
}}
engine := gin.New()
NewServer(&config.Config{AuthKey: authKey}, fixture.service).RegisterRoutes(engine)
recorder := serveDiscoveryRequest(t, engine, authKey,
`{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},`+
`"credentials":"sk-upstream"}`,
)
if recorder.Code != http.StatusBadGateway || !strings.Contains(recorder.Body.String(), `"code":"BAD_GATEWAY"`) {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
if strings.Contains(recorder.Body.String(), `{"data":[`) {
t.Fatalf("response exposes upstream body: %s", recorder.Body.String())
}
})
}
func TestServerDraftModelDiscoveryLogsOnlyMetadata(t *testing.T) {
// 不标记 t.Parallel():本测试劫持了全局 logrus 输出/格式,与其他并行测试同时运行会互相覆盖断言。
initControlI18n(t)
const (
authSecret = "distinctive-auth-secret"
keySecret = "distinctive-upstream-key"
bodySecret = "distinctive-upstream-body"
)
fixture := newServiceFixture(t)
fixture.service.executor = newRecordingDiscoveryExecutor(&recordingDiscoveryExecutorTarget{
value: protocol.Anthropic,
listFn: func(context.Context, string, string, state.HeaderRules) ([]string, error) {
return nil, fmt.Errorf("provider error: %s", bodySecret)
},
})
engine := gin.New()
NewServer(&config.Config{AuthKey: authSecret}, fixture.service).RegisterRoutes(engine)
var logs bytes.Buffer
previousOutput := logrus.StandardLogger().Out
logrus.SetOutput(&logs)
t.Cleanup(func() { logrus.SetOutput(previousOutput) })
recorder := serveDiscoveryRequest(t, engine, authSecret,
`{"channel_id":"openai_compatible","connection_type":"api_key","params":{"base_url":"https://api.example.com"},`+
`"credentials":"`+keySecret+`"}`,
)
if recorder.Code != http.StatusBadGateway {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
logText := logs.String()
for _, required := range []string{"operation=discover_models", "error_code=BAD_GATEWAY", "error_type="} {
if !strings.Contains(logText, required) {
t.Fatalf("logs missing %q: %s", required, logText)
}
}
if strings.Contains(logText, "protocol=") {
t.Fatalf("logs retain submitted protocol metadata: %s", logText)
}
for _, forbidden := range []string{authSecret, keySecret, bodySecret, "provider error", "Authorization"} {
if strings.Contains(logText, forbidden) {
t.Fatalf("logs expose %q: %s", forbidden, logText)
}
}
}
func TestServerGroupModelDiscoveryBodyContract(t *testing.T) {
t.Parallel()
initControlI18n(t)
const authKey = "test-auth-key"
newServer := func(t *testing.T, activeKey bool) (serviceFixture, *gin.Engine, uint) {
t.Helper()
fixture := newServiceFixture(t)
created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
ChannelID: channel.OpenAICompatible,
Params: json.RawMessage(`{"base_url":"https://persisted-server.example.com/v1"}`),
Models: optionalGroupModels{Set: true, Values: []GroupModel{}},
Credentials: "persisted-server-key", ConnectionType: "api_key",
})
if err != nil {
t.Fatalf("seed CreateGroup() error = %v", err)
}
if !activeKey {
if err := fixture.db.Model(&models.Credential{}).
Where("group_id = ?", created.GroupID).
Update("status", models.CredentialStatusDisabled).Error; err != nil {
t.Fatalf("disable persisted discovery key: %v", err)
}
}
fixture.service.executor = newRecordingDiscoveryExecutor(&recordingDiscoveryExecutorTarget{
value: protocol.OpenAICompletions,
listFn: func(context.Context, string, string, state.HeaderRules) ([]string, error) {
return []string{"z-model", "a-model"}, nil
},
})
engine := gin.New()
NewServer(&config.Config{AuthKey: authKey}, fixture.service).RegisterRoutes(engine)
return fixture, engine, created.GroupID
}
t.Run("optional empty object accepts only no body whitespace and empty object", func(t *testing.T) {
_, engine, groupID := newServer(t, true)
for _, test := range []struct {
name string
payload *string
}{
{name: "no body"},
{name: "whitespace", payload: stringPointer(" \n\t ")},
{name: "empty object", payload: stringPointer("{}")},
} {
t.Run(test.name, func(t *testing.T) {
recorder := serveGroupDiscoveryRequest(t, engine, authKey, "en-US", groupID, test.payload)
if recorder.Code != http.StatusOK {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
var body struct {
Code int `json:"code"`
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode success response: %v", err)
}
if body.Code != 0 || len(body.Data) != 1 {
t.Fatalf("success response = %#v", body)
}
var gotModels []ModelCandidate
if err := json.Unmarshal(body.Data["models"], &gotModels); err != nil ||
!reflect.DeepEqual(gotModels, []ModelCandidate{
{ID: "z-model", Name: "z-model", Sources: []string{"live"}, PricingStatus: PricingStatusPending},
{ID: "a-model", Name: "a-model", Sources: []string{"live"}, PricingStatus: PricingStatusPending},
}) {
t.Fatalf("models = %#v, error=%v", gotModels, err)
}
})
}
for _, payload := range []string{"null", "[]", `{"refresh":true}`, "{} {}"} {
t.Run("reject "+payload, func(t *testing.T) {
recorder := serveGroupDiscoveryRequest(
t, engine, authKey, "en-US", groupID, stringPointer(payload),
)
if recorder.Code != http.StatusBadRequest ||
!strings.Contains(recorder.Body.String(), `"code":"INVALID_JSON"`) {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
})
}
})
t.Run("invalid Group IDs return bad request", func(t *testing.T) {
_, engine, _ := newServer(t, true)
for _, rawID := range []string{"0", "not-a-number", "18446744073709551616"} {
recorder := serveRawGroupDiscoveryRequest(t, engine, authKey, "en-US", rawID, nil)
if recorder.Code != http.StatusBadRequest ||
!strings.Contains(recorder.Body.String(), `"code":"BAD_REQUEST"`) {
t.Fatalf("Group ID %q response = %d %s", rawID, recorder.Code, recorder.Body.String())
}
}
})
t.Run("missing Group is localized not found", func(t *testing.T) {
_, engine, _ := newServer(t, true)
recorder := serveRawGroupDiscoveryRequest(t, engine, authKey, "zh-CN", "999", nil)
if recorder.Code != http.StatusNotFound ||
!strings.Contains(recorder.Body.String(), `"code":"NOT_FOUND"`) ||
!strings.Contains(recorder.Body.String(), "分组不存在") {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
})
t.Run("no active key is localized conflict", func(t *testing.T) {
_, engine, groupID := newServer(t, false)
for _, test := range []struct {
language string
message string
}{
{language: "en-US", message: "No active credential is available for this group"},
{language: "zh-CN", message: "该分组没有可用凭据"},
{language: "ja-JP", message: "このグループには利用可能な認証情報がありません"},
} {
recorder := serveGroupDiscoveryRequest(
t, engine, authKey, test.language, groupID, nil,
)
if recorder.Code != http.StatusConflict ||
!strings.Contains(recorder.Body.String(), `"code":"NO_ACTIVE_CREDENTIAL"`) ||
!strings.Contains(recorder.Body.String(), test.message) {
t.Fatalf("%s response = %d %s", test.language, recorder.Code, recorder.Body.String())
}
}
})
}
func serveGroupDiscoveryRequest(
t *testing.T,
engine *gin.Engine,
authKey, language string,
groupID uint,
payload *string,
) *httptest.ResponseRecorder {
t.Helper()
return serveRawGroupDiscoveryRequest(
t, engine, authKey, language, strconv.FormatUint(uint64(groupID), 10), payload,
)
}
func serveRawGroupDiscoveryRequest(
t *testing.T,
engine *gin.Engine,
authKey, language, groupID string,
payload *string,
) *httptest.ResponseRecorder {
t.Helper()
var request *http.Request
if payload != nil {
request = httptest.NewRequest(
http.MethodPost,
"/api/groups/"+groupID+"/models/discover",
strings.NewReader(*payload),
)
} else {
request = httptest.NewRequest(
http.MethodPost,
"/api/groups/"+groupID+"/models/discover",
nil,
)
}
recorder := httptest.NewRecorder()
request.Header.Set("Authorization", "Bearer "+authKey)
request.Header.Set("Accept-Language", language)
request.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(recorder, request)
return recorder
}
func serveDiscoveryRequest(t *testing.T, engine *gin.Engine, authKey, payload string) *httptest.ResponseRecorder {
t.Helper()
return serveDiscoveryRequestWithLanguage(t, engine, authKey, payload, "en-US")
}
func serveDiscoveryRequestWithLanguage(
t *testing.T,
engine *gin.Engine,
authKey, payload, language string,
) *httptest.ResponseRecorder {
t.Helper()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/models/discover", strings.NewReader(payload))
if authKey != "" {
request.Header.Set("Authorization", "Bearer "+authKey)
}
request.Header.Set("Accept-Language", language)
request.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(recorder, request)
return recorder
}
func TestSettingsHTTPContract(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
tests := []struct {
name string
method string
auth string
body string
wantStatus int
wantCode string
}{
{name: "get", method: http.MethodGet, auth: "Bearer test-auth-key", wantStatus: http.StatusOK},
{name: "unauthorized get", method: http.MethodGet, wantStatus: http.StatusUnauthorized, wantCode: "UNAUTHORIZED"},
{name: "unauthorized update", method: http.MethodPut, body: `{"settings":{"request_timeout":900}}`, wantStatus: http.StatusUnauthorized, wantCode: "UNAUTHORIZED"},
{name: "update", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{"request_timeout":900}}`, wantStatus: http.StatusOK},
{name: "unknown top level", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"other":{}}`, wantStatus: http.StatusBadRequest, wantCode: "INVALID_JSON"},
{name: "missing settings", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{}`, wantStatus: http.StatusBadRequest, wantCode: "INVALID_JSON"},
{name: "duplicate top level", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{"request_timeout":900},"settings":{"request_timeout":800}}`, wantStatus: http.StatusBadRequest, wantCode: "INVALID_JSON"},
{name: "duplicate nested", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{"request_timeout":900,"request_timeout":800}}`, wantStatus: http.StatusBadRequest, wantCode: "INVALID_JSON"},
{name: "duplicate header rules field", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{"header_rules":{"set":{},"set":{}}}}`, wantStatus: http.StatusBadRequest, wantCode: "INVALID_JSON"},
{name: "duplicate header member", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{"header_rules":{"set":{"X-Test":"one","X-Test":"two"}}}}`, wantStatus: http.StatusBadRequest, wantCode: "INVALID_JSON"},
{name: "duplicate future object in array", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{"header_rules":{"remove":[{"x":1,"x":2}]}}}`, wantStatus: http.StatusBadRequest, wantCode: "INVALID_JSON"},
{name: "null settings", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":null}`, wantStatus: http.StatusBadRequest, wantCode: "INVALID_JSON"},
{name: "array settings", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":[]}`, wantStatus: http.StatusBadRequest, wantCode: "INVALID_JSON"},
{name: "top level null", method: http.MethodPut, auth: "Bearer test-auth-key", body: `null`, wantStatus: http.StatusBadRequest, wantCode: "INVALID_JSON"},
{name: "multiple JSON values", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{}} {}`, wantStatus: http.StatusBadRequest, wantCode: "INVALID_JSON"},
{name: "malformed", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{"request_timeout":900}`, wantStatus: http.StatusBadRequest, wantCode: "INVALID_JSON"},
{name: "empty", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{}}`, wantStatus: http.StatusBadRequest, wantCode: "BAD_REQUEST"},
{name: "unknown setting", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{"unknown":true}}`, wantStatus: http.StatusBadRequest, wantCode: "VALIDATION_FAILED"},
{name: "internal setting", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{"_internal.marker":true}}`, wantStatus: http.StatusBadRequest, wantCode: "VALIDATION_FAILED"},
{name: "fractional timeout", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{"request_timeout":1.5}}`, wantStatus: http.StatusBadRequest, wantCode: "VALIDATION_FAILED"},
{name: "invalid retention", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{"request_log_retention_days":366}}`, wantStatus: http.StatusBadRequest, wantCode: "VALIDATION_FAILED"},
{name: "invalid header rules", method: http.MethodPut, auth: "Bearer test-auth-key", body: `{"settings":{"header_rules":{"append":{}}}}`, wantStatus: http.StatusBadRequest, wantCode: "VALIDATION_FAILED"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
before := fixture.manager.Current()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(test.method, "/api/settings", strings.NewReader(test.body))
if test.auth != "" {
request.Header.Set("Authorization", test.auth)
}
request.Header.Set("Accept-Language", "en-US")
request.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(recorder, request)
if recorder.Code != test.wantStatus {
t.Fatalf("response = %d %s, want %d", recorder.Code, recorder.Body.String(), test.wantStatus)
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if test.wantCode != "" {
var code string
if err := json.Unmarshal(envelope["code"], &code); err != nil || code != test.wantCode {
t.Fatalf("body = %s, code error=%v", recorder.Body.String(), err)
}
if len(envelope) != 2 {
t.Fatalf("error envelope fields = %#v, want code and message only", envelope)
}
if fixture.manager.Current() != before {
t.Fatal("rejected Settings request published a Snapshot")
}
} else if len(envelope) != 3 {
t.Fatalf("success envelope fields = %#v, want code message data", envelope)
}
})
}
}
func TestSettingsHTTPSuccessEnvelopeUpdateAndReset(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
assertResponse := func(recorder *httptest.ResponseRecorder, timeout int64, overrides []string) {
t.Helper()
if recorder.Code != http.StatusOK {
t.Fatalf("Settings response = %d %s", recorder.Code, recorder.Body.String())
}
assertSettingsResponseHeaders(t, recorder, "en-US")
var envelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data SettingsResponse `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if envelope.Code != 0 || envelope.Message != "Success" ||
envelope.Data.Values.RequestTimeout != timeout ||
!reflect.DeepEqual(envelope.Data.Overrides, overrides) {
t.Fatalf("Settings response = %#v, want timeout=%d overrides=%v", envelope, timeout, overrides)
}
if strings.Contains(recorder.Body.String(), `"revision"`) {
t.Fatalf("Settings response exposes process revision: %s", recorder.Body.String())
}
}
get := serveSettingsRequest(t, engine, http.MethodGet, "test-auth-key", "")
assertResponse(get, 600, []string{})
update := serveSettingsRequest(t, engine, http.MethodPut, "test-auth-key", `{"settings":{"request_timeout":900,"header_rules":{"set":{},"remove":[]}}}`)
assertResponse(update, 900, []string{"header_rules", "request_timeout"})
reset := serveSettingsRequest(t, engine, http.MethodPut, "test-auth-key", `{"settings":{"request_timeout":null,"header_rules":null}}`)
assertResponse(reset, 600, []string{})
}
func TestSettingsHTTPBodyLimitRejectsBeforeMutation(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
before := fixture.manager.Current()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPut,
"/api/settings",
oversizedControlJSONBody(`{"settings":{"request_timeout":900}}`),
)
request.ContentLength = -1
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Accept-Language", "en-US")
request.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusRequestEntityTooLarge ||
!strings.Contains(recorder.Body.String(), `"code":"REQUEST_TOO_LARGE"`) {
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
}
if fixture.manager.Current() != before {
t.Fatal("oversized Settings request published a Snapshot")
}
var count int64
if err := fixture.db.Model(&models.SystemSetting{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("system setting rows = %d, want 0", count)
}
}
func TestSettingsHTTPFiltersPrivateRowsAndDoesNotLogValues(t *testing.T) {
// 不标记 t.Parallel():本测试劫持了全局 logrus 输出/格式,与其他并行测试同时运行会互相覆盖断言。
initControlI18n(t)
const (
authKey = "settings-auth-key"
distinctiveValue = "distinctive-global-header-secret"
unknownKey = "unknown-distinctive-setting"
)
fixture := newServiceFixture(t)
engine := gin.New()
NewServer(&config.Config{AuthKey: authKey}, fixture.service).RegisterRoutes(engine)
update := serveSettingsRequest(
t,
engine,
http.MethodPut,
authKey,
`{"settings":{"header_rules":{"set":{"X-Distinctive":"`+distinctiveValue+`"}}}}`,
)
if update.Code != http.StatusOK {
t.Fatalf("update response = %d %s", update.Code, update.Body.String())
}
for _, row := range []models.SystemSetting{
{Key: defaultAccessKeyMarker, Value: `"bootstrap-marker-distinctive"`, UpdatedAtMS: time.Now().UnixMilli()},
{Key: unknownKey, Value: `"unknown-value"`, UpdatedAtMS: time.Now().UnixMilli()},
} {
if err := fixture.db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
get := serveSettingsRequest(t, engine, http.MethodGet, authKey, "")
if get.Code != http.StatusOK || !strings.Contains(get.Body.String(), distinctiveValue) ||
!strings.Contains(get.Body.String(), `"overrides":["header_rules"]`) {
t.Fatalf("authorized response = %d %s", get.Code, get.Body.String())
}
for _, forbidden := range []string{defaultAccessKeyMarker, "bootstrap-marker-distinctive", unknownKey, "unknown-value"} {
if strings.Contains(get.Body.String(), forbidden) {
t.Fatalf("authorized response exposes %q: %s", forbidden, get.Body.String())
}
}
unauthorized := serveSettingsRequest(t, engine, http.MethodGet, "", "")
if unauthorized.Code != http.StatusUnauthorized ||
strings.Contains(unauthorized.Body.String(), distinctiveValue) {
t.Fatalf("unauthorized response = %d %s", unauthorized.Code, unauthorized.Body.String())
}
var logs bytes.Buffer
logger := logrus.StandardLogger()
previousOutput, previousFormatter := logger.Out, logger.Formatter
logrus.SetOutput(&logs)
logrus.SetFormatter(&logrus.JSONFormatter{DisableTimestamp: true})
t.Cleanup(func() {
logrus.SetOutput(previousOutput)
logrus.SetFormatter(previousFormatter)
})
brokenService := NewService(
fixture.db,
state.NewManager(),
fixture.registry,
fixture.priceRuntime,
fixture.catalogRuntime,
nil,
fixture.encryption,
fixture.service.executor,
nil,
fixture.service.requestLogs,
fixture.service.usageStats,
fixture.service.homeStatistics,
fixture.stats,
fixture.mutations,
fixture.requestLogStats,
nil,
)
brokenEngine := gin.New()
NewServer(&config.Config{AuthKey: authKey}, brokenService).RegisterRoutes(brokenEngine)
failure := serveSettingsRequest(t, brokenEngine, http.MethodGet, authKey, "")
if failure.Code != http.StatusInternalServerError {
t.Fatalf("failure response = %d %s", failure.Code, failure.Body.String())
}
logText := logs.String()
if !strings.Contains(logText, `"operation":"get_settings"`) ||
!strings.Contains(logText, `"error_code":"INTERNAL_SERVER_ERROR"`) {
t.Fatalf("Settings failure log = %s", logText)
}
for _, forbidden := range []string{
distinctiveValue,
defaultAccessKeyMarker,
"bootstrap-marker-distinctive",
unknownKey,
"unknown-value",
authKey,
} {
if strings.Contains(logText, forbidden) {
t.Fatalf("Settings logs expose %q: %s", forbidden, logText)
}
}
}
func serveSettingsRequest(
t *testing.T,
engine *gin.Engine,
method string,
authKey string,
body string,
) *httptest.ResponseRecorder {
t.Helper()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(method, "/api/settings", strings.NewReader(body))
if authKey != "" {
request.Header.Set("Authorization", "Bearer "+authKey)
}
request.Header.Set("Accept-Language", "en-US")
request.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(recorder, request)
return recorder
}