| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- package service
- import (
- "context"
- "encoding/base64"
- "errors"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "google.golang.org/grpc"
- )
- type pluginTokenRepository struct {
- PluginRepository
- installation *PluginInstallation
- listErr error
- }
- func (r *pluginTokenRepository) List(context.Context) ([]*PluginInstallation, error) {
- if r.listErr != nil {
- return nil, r.listErr
- }
- if r.installation == nil {
- return nil, nil
- }
- copy := *r.installation
- return []*PluginInstallation{©}, nil
- }
- func (r *pluginTokenRepository) GetByID(context.Context, int64) (*PluginInstallation, error) {
- if r.installation == nil {
- return nil, errors.New("插件不存在")
- }
- copy := *r.installation
- return ©, nil
- }
- type pluginTokenEncryptor struct{}
- func (pluginTokenEncryptor) Encrypt(plaintext string) (string, error) {
- return "ENC:" + plaintext, nil
- }
- func (pluginTokenEncryptor) Decrypt(ciphertext string) (string, error) {
- if !strings.HasPrefix(ciphertext, "ENC:") {
- return "", errors.New("密文无效")
- }
- return strings.TrimPrefix(ciphertext, "ENC:"), nil
- }
- func TestPluginUIAssetTokenCanBeResolvedByAnotherInstance(t *testing.T) {
- repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}}
- first := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}}
- second := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}}
- token, expires, err := first.CreateUIAssetToken(context.Background(), 42, 30*time.Minute)
- require.NoError(t, err)
- require.WithinDuration(t, time.Now().Add(30*time.Minute), expires, time.Second)
- pluginID, err := second.ResolveUIAssetToken(token)
- require.NoError(t, err)
- require.Equal(t, int64(42), pluginID)
- decoded, err := base64.RawURLEncoding.DecodeString(token)
- require.NoError(t, err)
- decoded[len(decoded)-1] ^= 1
- _, err = second.ResolveUIAssetToken(base64.RawURLEncoding.EncodeToString(decoded))
- require.Error(t, err)
- }
- func TestPluginUIAssetTokenRejectsOtherEncryptedPayloads(t *testing.T) {
- repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}}
- manager := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}}
- encrypted, err := manager.encryptor.Encrypt(`{"version":1,"plugin_id":42,"expires":4102444800}`)
- require.NoError(t, err)
- token := base64.RawURLEncoding.EncodeToString([]byte(encrypted))
- _, err = manager.ResolveUIAssetToken(token)
- require.ErrorContains(t, err, "会话无效")
- }
- func TestPluginReconcileFailsClosedWhenDesiredStateCannotBeRead(t *testing.T) {
- manager := &PluginManager{
- repo: &pluginTokenRepository{listErr: errors.New("数据库不可用")},
- runtimes: make(map[int64]*pluginRuntime),
- localInstallations: make(map[int64]*PluginInstallation),
- }
- err := manager.reconcileOnce(context.Background())
- require.ErrorContains(t, err, "读取插件启用状态")
- require.True(t, manager.ShouldRouteOpenAIOAuth(&Account{
- ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
- }))
- request, requestErr := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil)
- require.NoError(t, requestErr)
- _, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", &Account{
- ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
- })
- require.True(t, handled)
- require.ErrorContains(t, routeErr, "插件不可用")
- }
- type normalizingPluginClient struct {
- pluginv1.TransportPluginClient
- normalized []byte
- applied []byte
- }
- type pluginConfigRepository struct {
- PluginRepository
- installation *PluginInstallation
- encrypted string
- }
- func (r *pluginConfigRepository) GetByID(context.Context, int64) (*PluginInstallation, error) {
- copy := *r.installation
- return ©, nil
- }
- func (r *pluginConfigRepository) UpdateConfig(_ context.Context, _ int64, encrypted, expectedBinarySHA256 string) error {
- if expectedBinarySHA256 != r.installation.BinarySHA256 {
- return ErrPluginStateChanged
- }
- r.encrypted = encrypted
- return nil
- }
- func (c *normalizingPluginClient) ValidateConfig(context.Context, *pluginv1.ValidateConfigRequest, ...grpc.CallOption) (*pluginv1.ValidateConfigResponse, error) {
- return &pluginv1.ValidateConfigResponse{Valid: true, NormalizedConfigJson: c.normalized}, nil
- }
- func (c *normalizingPluginClient) ApplyConfig(_ context.Context, request *pluginv1.ApplyConfigRequest, _ ...grpc.CallOption) (*pluginv1.ApplyConfigResponse, error) {
- c.applied = append([]byte(nil), request.ConfigJson...)
- return &pluginv1.ApplyConfigResponse{Applied: true}, nil
- }
- func TestPluginRuntimeReturnsAndAppliesNormalizedConfig(t *testing.T) {
- client := &normalizingPluginClient{normalized: []byte(`{"z":2,"a":1}`)}
- runtime := &pluginRuntime{api: client}
- normalized, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{"input":true}`))
- require.NoError(t, err)
- require.JSONEq(t, `{"a":1,"z":2}`, string(normalized))
- require.Equal(t, normalized, client.applied)
- }
- func TestPluginRuntimeRejectsInvalidNormalizedConfig(t *testing.T) {
- client := &normalizingPluginClient{normalized: []byte(`{"broken"`)}
- runtime := &pluginRuntime{api: client}
- _, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{}`))
- require.ErrorContains(t, err, "规范化配置")
- require.Empty(t, client.applied)
- }
- func TestPluginManagerPersistsPluginNormalizedConfig(t *testing.T) {
- installation := &PluginInstallation{ID: 9, BinarySHA256: strings.Repeat("a", 64)}
- repo := &pluginConfigRepository{installation: installation}
- client := &normalizingPluginClient{normalized: []byte(`{"timeout":30,"enabled":true}`)}
- manager := &PluginManager{
- repo: repo, encryptor: pluginTokenEncryptor{},
- runtimes: map[int64]*pluginRuntime{9: {installation: installation, api: client}},
- }
- saved, err := manager.SaveConfig(context.Background(), 9, []byte(`{"enabled":false}`))
- require.NoError(t, err)
- require.JSONEq(t, `{"enabled":true,"timeout":30}`, string(saved))
- plaintext, err := (pluginTokenEncryptor{}).Decrypt(repo.encrypted)
- require.NoError(t, err)
- require.JSONEq(t, string(saved), plaintext)
- }
- func TestPluginRequestSentErrorDoesNotFailOver(t *testing.T) {
- gin.SetMode(gin.TestMode)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
- account := &Account{ID: 7, Name: "oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth}
- transportErr := &PluginTransportError{Code: "UPSTREAM_EOF", Message: "eof", RequestSent: true}
- result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, transportErr, true)
- require.Same(t, transportErr, result)
- var failover *UpstreamFailoverError
- require.False(t, errors.As(result, &failover))
- }
- func TestPluginRPCAmbiguityPreventsReplayAfterMetadataDelivery(t *testing.T) {
- err := normalizePluginRPCError(context.Background(), "接收插件响应头", errors.New("连接已断开"), true)
- var transportErr *PluginTransportError
- require.ErrorAs(t, err, &transportErr)
- require.True(t, transportErr.RequestSent)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
- account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
- result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true)
- require.Same(t, err, result)
- }
- func TestPluginRPCFailureBeforeStreamCreationAllowsFailover(t *testing.T) {
- err := normalizePluginRPCError(context.Background(), "创建插件转发流", errors.New("连接失败"), false)
- var transportErr *PluginTransportError
- require.ErrorAs(t, err, &transportErr)
- require.False(t, transportErr.RequestSent)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
- account := &Account{ID: 13, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
- result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true)
- var failover *UpstreamFailoverError
- require.ErrorAs(t, result, &failover)
- }
- func TestNormalizePluginRPCErrorPreservesCallerCancellation(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- err := normalizePluginRPCError(ctx, "接收响应", errors.New("rpc error: code = Canceled"), true)
- require.ErrorIs(t, err, context.Canceled)
- }
- func TestPluginStartingStateUsesBoundedCrashRecoveryWindow(t *testing.T) {
- manager := &PluginManager{cfg: &config.Config{Plugins: config.PluginConfig{StartTimeoutSeconds: 15}}}
- require.False(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-30 * time.Second)}))
- require.True(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-2 * time.Minute)}))
- }
|