Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type duplicateChannelMonitorRepoStub struct {
|
||||
ChannelMonitorRepository
|
||||
source *ChannelMonitor
|
||||
created []*ChannelMonitor
|
||||
byOperation map[string]*ChannelMonitor
|
||||
nextID int64
|
||||
}
|
||||
|
||||
func (r *duplicateChannelMonitorRepoStub) GetByID(_ context.Context, id int64) (*ChannelMonitor, error) {
|
||||
if r.source == nil || r.source.ID != id {
|
||||
return nil, ErrChannelMonitorNotFound
|
||||
}
|
||||
return r.source, nil
|
||||
}
|
||||
|
||||
func (r *duplicateChannelMonitorRepoStub) Create(_ context.Context, monitor *ChannelMonitor) error {
|
||||
r.nextID++
|
||||
monitor.ID = 100 + r.nextID
|
||||
monitor.CreatedAt = time.Date(2026, time.July, 16, 8, 0, 0, 0, time.UTC)
|
||||
monitor.UpdatedAt = monitor.CreatedAt
|
||||
|
||||
stored := *monitor
|
||||
stored.ExtraModels = append([]string(nil), monitor.ExtraModels...)
|
||||
stored.ExtraHeaders = cloneStringMap(monitor.ExtraHeaders)
|
||||
stored.BodyOverride = mustCloneJSONMap(monitor.BodyOverride)
|
||||
if monitor.TemplateID != nil {
|
||||
templateID := *monitor.TemplateID
|
||||
stored.TemplateID = &templateID
|
||||
}
|
||||
r.created = append(r.created, &stored)
|
||||
if stored.DuplicateOperationID != "" {
|
||||
if r.byOperation == nil {
|
||||
r.byOperation = make(map[string]*ChannelMonitor)
|
||||
}
|
||||
r.byOperation[stored.DuplicateOperationID] = &stored
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *duplicateChannelMonitorRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*ChannelMonitor, error) {
|
||||
monitor := r.byOperation[operationID]
|
||||
if monitor == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cloned := *monitor
|
||||
cloned.ExtraModels = append([]string(nil), monitor.ExtraModels...)
|
||||
cloned.ExtraHeaders = cloneStringMap(monitor.ExtraHeaders)
|
||||
cloned.BodyOverride = mustCloneJSONMap(monitor.BodyOverride)
|
||||
return &cloned, nil
|
||||
}
|
||||
|
||||
func cloneStringMap(source map[string]string) map[string]string {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]string, len(source))
|
||||
for key, value := range source {
|
||||
cloned[key] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func mustCloneJSONMap(source map[string]any) map[string]any {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
cloned, err := cloneChannelMonitorJSONMap(source)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
type duplicateChannelMonitorEncryptor struct {
|
||||
decryptErr error
|
||||
encryptErr error
|
||||
}
|
||||
|
||||
func (e *duplicateChannelMonitorEncryptor) Encrypt(plaintext string) (string, error) {
|
||||
if e.encryptErr != nil {
|
||||
return "", e.encryptErr
|
||||
}
|
||||
return "NEW:" + plaintext, nil
|
||||
}
|
||||
|
||||
func (e *duplicateChannelMonitorEncryptor) Decrypt(ciphertext string) (string, error) {
|
||||
if e.decryptErr != nil {
|
||||
return "", e.decryptErr
|
||||
}
|
||||
if !strings.HasPrefix(ciphertext, "OLD:") && !strings.HasPrefix(ciphertext, "NEW:") {
|
||||
return "", errors.New("invalid ciphertext")
|
||||
}
|
||||
return strings.TrimPrefix(strings.TrimPrefix(ciphertext, "OLD:"), "NEW:"), nil
|
||||
}
|
||||
|
||||
func TestDuplicateChannelMonitorCopiesConfigurationAndResetsRuntimeState(t *testing.T) {
|
||||
lastCheckedAt := time.Date(2026, time.July, 15, 7, 0, 0, 0, time.UTC)
|
||||
templateID := int64(9)
|
||||
source := &ChannelMonitor{
|
||||
ID: 42,
|
||||
Name: "primary",
|
||||
Provider: MonitorProviderOpenAI,
|
||||
APIMode: MonitorAPIModeResponses,
|
||||
Endpoint: "https://api.example.com",
|
||||
APIKey: "OLD:top-secret",
|
||||
PrimaryModel: "gpt-5.4-mini",
|
||||
ExtraModels: []string{"gpt-5.4", "gpt-5.3"},
|
||||
GroupName: "production",
|
||||
Enabled: true,
|
||||
IntervalSeconds: 90,
|
||||
JitterSeconds: 15,
|
||||
LastCheckedAt: &lastCheckedAt,
|
||||
CreatedBy: 4,
|
||||
CreatedAt: lastCheckedAt.Add(-time.Hour),
|
||||
UpdatedAt: lastCheckedAt,
|
||||
TemplateID: &templateID,
|
||||
ExtraHeaders: map[string]string{"User-Agent": "Codex"},
|
||||
BodyOverrideMode: MonitorBodyOverrideModeMerge,
|
||||
BodyOverride: map[string]any{
|
||||
"metadata": map[string]any{"source": "original"},
|
||||
},
|
||||
}
|
||||
repo := &duplicateChannelMonitorRepoStub{source: source}
|
||||
service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{})
|
||||
|
||||
duplicate, err := service.Duplicate(context.Background(), source.ID, 77, "admin:77", "copy-primary")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, repo.created, 1)
|
||||
stored := repo.created[0]
|
||||
require.NotEqual(t, source.ID, duplicate.ID)
|
||||
require.Equal(t, "primary (Copy)", duplicate.Name)
|
||||
require.Equal(t, source.Provider, duplicate.Provider)
|
||||
require.Equal(t, source.APIMode, duplicate.APIMode)
|
||||
require.Equal(t, source.Endpoint, duplicate.Endpoint)
|
||||
require.Equal(t, "top-secret", duplicate.APIKey)
|
||||
require.Equal(t, "NEW:top-secret", stored.APIKey)
|
||||
require.Equal(t, source.PrimaryModel, duplicate.PrimaryModel)
|
||||
require.Equal(t, source.ExtraModels, duplicate.ExtraModels)
|
||||
require.Equal(t, source.GroupName, duplicate.GroupName)
|
||||
require.Equal(t, source.IntervalSeconds, duplicate.IntervalSeconds)
|
||||
require.Equal(t, source.JitterSeconds, duplicate.JitterSeconds)
|
||||
require.Equal(t, source.TemplateID, duplicate.TemplateID)
|
||||
require.Equal(t, source.ExtraHeaders, duplicate.ExtraHeaders)
|
||||
require.Equal(t, source.BodyOverrideMode, duplicate.BodyOverrideMode)
|
||||
require.Equal(t, source.BodyOverride, duplicate.BodyOverride)
|
||||
require.False(t, duplicate.Enabled)
|
||||
require.Nil(t, duplicate.LastCheckedAt)
|
||||
require.Equal(t, int64(77), duplicate.CreatedBy)
|
||||
require.False(t, duplicate.APIKeyDecryptFailed)
|
||||
require.NotEmpty(t, duplicate.DuplicateOperationID)
|
||||
|
||||
duplicate.ExtraModels[0] = "changed"
|
||||
duplicate.ExtraHeaders["User-Agent"] = "changed"
|
||||
duplicate.BodyOverride["metadata"].(map[string]any)["source"] = "changed"
|
||||
*duplicate.TemplateID = 10
|
||||
require.Equal(t, []string{"gpt-5.4", "gpt-5.3"}, source.ExtraModels)
|
||||
require.Equal(t, "Codex", source.ExtraHeaders["User-Agent"])
|
||||
require.Equal(t, "original", source.BodyOverride["metadata"].(map[string]any)["source"])
|
||||
require.Equal(t, int64(9), *source.TemplateID)
|
||||
require.Equal(t, "OLD:top-secret", source.APIKey)
|
||||
require.True(t, source.Enabled)
|
||||
require.Equal(t, &lastCheckedAt, source.LastCheckedAt)
|
||||
}
|
||||
|
||||
func TestDuplicateChannelMonitorNamePreservesSuffixWithinSchemaLimit(t *testing.T) {
|
||||
name := duplicateChannelMonitorName(strings.Repeat("界", 100))
|
||||
|
||||
require.Equal(t, 100, utf8.RuneCountInString(name))
|
||||
require.True(t, strings.HasSuffix(name, " (Copy)"))
|
||||
}
|
||||
|
||||
func TestDuplicateChannelMonitorRejectsUndecryptableAPIKey(t *testing.T) {
|
||||
source := &ChannelMonitor{ID: 42, Name: "broken", APIKey: "OLD:broken"}
|
||||
repo := &duplicateChannelMonitorRepoStub{source: source}
|
||||
service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{decryptErr: errors.New("wrong encryption key")})
|
||||
|
||||
duplicate, err := service.Duplicate(context.Background(), source.ID, 77, "admin:77", "copy-broken")
|
||||
|
||||
require.Nil(t, duplicate)
|
||||
require.ErrorIs(t, err, ErrChannelMonitorAPIKeyDecryptFailed)
|
||||
require.Empty(t, repo.created)
|
||||
require.Equal(t, "OLD:broken", source.APIKey)
|
||||
}
|
||||
|
||||
func TestDuplicateChannelMonitorRecoversCommittedCopyForSameOperation(t *testing.T) {
|
||||
source := &ChannelMonitor{
|
||||
ID: 42,
|
||||
Name: "primary",
|
||||
Provider: MonitorProviderOpenAI,
|
||||
APIMode: MonitorAPIModeResponses,
|
||||
Endpoint: "https://api.example.com",
|
||||
APIKey: "OLD:top-secret",
|
||||
PrimaryModel: "gpt-5.4-mini",
|
||||
IntervalSeconds: 60,
|
||||
BodyOverrideMode: MonitorBodyOverrideModeOff,
|
||||
}
|
||||
repo := &duplicateChannelMonitorRepoStub{source: source}
|
||||
service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{})
|
||||
|
||||
first, err := service.Duplicate(context.Background(), source.ID, 77, "admin:77", "stable-key")
|
||||
require.NoError(t, err)
|
||||
retry, err := service.Duplicate(context.Background(), source.ID, 77, "admin:77", "stable-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, repo.created, 1, "same operation must not create a second monitor")
|
||||
require.Equal(t, first.ID, retry.ID)
|
||||
require.Equal(t, "top-secret", retry.APIKey)
|
||||
require.Equal(t, first.DuplicateOperationID, retry.DuplicateOperationID)
|
||||
require.NotContains(t, retry.ExtraHeaders, ChannelMonitorDuplicateOperationIDMetadataKey)
|
||||
|
||||
otherActor, err := service.Duplicate(context.Background(), source.ID, 88, "admin:88", "stable-key")
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, first.ID, otherActor.ID)
|
||||
require.Len(t, repo.created, 2, "operation identity must include the actor scope")
|
||||
}
|
||||
|
||||
func TestChannelMonitorDuplicateOperationMetadataKeyCannotBeSubmittedAsHeader(t *testing.T) {
|
||||
err := validateExtraHeaders(map[string]string{
|
||||
ChannelMonitorDuplicateOperationIDMetadataKey: "forged-operation",
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user