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,295 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type duplicateAccountAdminServiceStub struct {
|
||||
service.AdminService
|
||||
account *service.Account
|
||||
calls int
|
||||
recoverCalls int
|
||||
accountID int64
|
||||
actorScope string
|
||||
operationKey string
|
||||
recoverScope string
|
||||
recoverKey string
|
||||
recoverErr error
|
||||
created bool
|
||||
}
|
||||
|
||||
type blockingDuplicateAdminServiceStub struct {
|
||||
service.AdminService
|
||||
account *service.Account
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
calls atomic.Int32
|
||||
recoverCalls atomic.Int32
|
||||
recoverErr error
|
||||
}
|
||||
|
||||
type failOnceMarkSucceededRepo struct {
|
||||
*memoryIdempotencyRepoStub
|
||||
failNext bool
|
||||
}
|
||||
|
||||
func (r *failOnceMarkSucceededRepo) MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
|
||||
if r.failNext {
|
||||
r.failNext = false
|
||||
return errors.New("mark succeeded failed")
|
||||
}
|
||||
return r.memoryIdempotencyRepoStub.MarkSucceeded(ctx, id, responseStatus, responseBody, expiresAt)
|
||||
}
|
||||
|
||||
func (s *duplicateAccountAdminServiceStub) DuplicateAccount(_ context.Context, accountID int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
s.calls++
|
||||
s.accountID = accountID
|
||||
s.actorScope = actorScope
|
||||
s.operationKey = operationKey
|
||||
s.created = true
|
||||
return s.account, nil
|
||||
}
|
||||
|
||||
func (s *duplicateAccountAdminServiceStub) RecoverDuplicateAccount(_ context.Context, _ int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
s.recoverCalls++
|
||||
s.recoverScope = actorScope
|
||||
s.recoverKey = operationKey
|
||||
if s.recoverErr != nil {
|
||||
return nil, s.recoverErr
|
||||
}
|
||||
if !s.created {
|
||||
return nil, nil
|
||||
}
|
||||
return s.account, nil
|
||||
}
|
||||
|
||||
func (s *blockingDuplicateAdminServiceStub) DuplicateAccount(_ context.Context, _ int64, _, _ string) (*service.Account, error) {
|
||||
s.calls.Add(1)
|
||||
close(s.started)
|
||||
<-s.release
|
||||
return s.account, nil
|
||||
}
|
||||
|
||||
func (s *blockingDuplicateAdminServiceStub) RecoverDuplicateAccount(_ context.Context, _ int64, _, _ string) (*service.Account, error) {
|
||||
s.recoverCalls.Add(1)
|
||||
return nil, s.recoverErr
|
||||
}
|
||||
|
||||
func setupDuplicateAccountRouter(t *testing.T, svc service.AdminService) *gin.Engine {
|
||||
t.Helper()
|
||||
previousCoordinator := service.DefaultIdempotencyCoordinator()
|
||||
service.SetDefaultIdempotencyCoordinator(nil)
|
||||
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
|
||||
c.Next()
|
||||
})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/:id/duplicate", handler.Duplicate)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerRedactsCredentials(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
Credentials: map[string]any{"api_key": "top-secret-key"},
|
||||
},
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Contains(t, recorder.Body.String(), `"name":"primary (Copy)"`)
|
||||
require.NotContains(t, recorder.Body.String(), "top-secret-key")
|
||||
var responseBody struct {
|
||||
Data struct {
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Schedulable bool `json:"schedulable"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &responseBody))
|
||||
require.Empty(t, responseBody.Data.Credentials)
|
||||
require.False(t, responseBody.Data.Schedulable)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerRejectsInvalidID(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/not-a-number/duplicate", nil)
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Zero(t, svc.calls)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerReplaysSameIdempotencyKey(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
repo := newMemoryIdempotencyRepoStub()
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-account-42")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
first := call()
|
||||
second := call()
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Equal(t, int64(42), svc.accountID)
|
||||
require.Equal(t, "admin:77", svc.actorScope)
|
||||
require.Equal(t, "duplicate-account-42", svc.operationKey)
|
||||
require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-account-42-recovery")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
first := call()
|
||||
second := call()
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, 1, svc.calls, "ambiguous retries must not repeat the create side effect")
|
||||
require.Equal(t, 2, svc.recoverCalls)
|
||||
require.Equal(t, "admin:77", svc.recoverScope)
|
||||
require.Equal(t, "duplicate-account-42-recovery", svc.recoverKey)
|
||||
require.Contains(t, second.Body.String(), `"id":43`)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerPreservesIdempotencyErrorWhenRecoveryLookupFails(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
recoverErr: errors.New("recovery database unavailable"),
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-account-42-recovery-error")
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "IDEMPOTENCY_STORE_UNAVAILABLE")
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Equal(t, 1, svc.recoverCalls)
|
||||
require.Equal(t, "admin:77", svc.recoverScope)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerDoesNotReexecuteWhileOriginalIsProcessing(t *testing.T) {
|
||||
svc := &blockingDuplicateAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
recoverErr: errors.New("recovery database unavailable"),
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-account-42-active")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
firstDone := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { firstDone <- call() }()
|
||||
<-svc.started
|
||||
|
||||
second := call()
|
||||
require.Equal(t, http.StatusConflict, second.Code)
|
||||
require.Contains(t, second.Body.String(), "IDEMPOTENCY_IN_PROGRESS")
|
||||
require.Equal(t, int32(1), svc.calls.Load())
|
||||
require.Equal(t, int32(1), svc.recoverCalls.Load())
|
||||
|
||||
close(svc.release)
|
||||
select {
|
||||
case first := <-firstDone:
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("original duplicate request did not finish")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user