2026-04-09 14:28:17 +08:00
|
|
|
|
//go:build e2e
|
|
|
|
|
|
// +build e2e
|
|
|
|
|
|
|
|
|
|
|
|
package e2e
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-11 09:25:31 +08:00
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
|
"strings"
|
2026-04-09 14:28:17 +08:00
|
|
|
|
"testing"
|
|
|
|
|
|
"time"
|
2026-04-11 09:25:31 +08:00
|
|
|
|
|
|
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
|
|
|
|
|
|
|
|
|
|
"lijiaoqiao/supply-api/internal/adapter"
|
|
|
|
|
|
"lijiaoqiao/supply-api/internal/audit"
|
|
|
|
|
|
"lijiaoqiao/supply-api/internal/domain"
|
|
|
|
|
|
"lijiaoqiao/supply-api/internal/httpapi"
|
|
|
|
|
|
"lijiaoqiao/supply-api/internal/middleware"
|
|
|
|
|
|
"lijiaoqiao/supply-api/internal/pkg/logging"
|
2026-04-09 14:28:17 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
type e2eOptions struct {
|
|
|
|
|
|
withdrawEnabled bool
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
type e2eSystem struct {
|
|
|
|
|
|
handler http.Handler
|
|
|
|
|
|
accountSvc *e2eAccountService
|
|
|
|
|
|
auditStore *audit.MemoryAuditStore
|
|
|
|
|
|
secretKey string
|
|
|
|
|
|
tokenIssuer string
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
type e2eAccountService struct {
|
|
|
|
|
|
verifyResult *domain.VerifyResult
|
|
|
|
|
|
lastVerifySupplierID int64
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
func (s *e2eAccountService) Verify(ctx context.Context, supplierID int64, provider domain.Provider, accountType domain.AccountType, credential string) (*domain.VerifyResult, error) {
|
|
|
|
|
|
s.lastVerifySupplierID = supplierID
|
|
|
|
|
|
return s.verifyResult, nil
|
|
|
|
|
|
}
|
2026-04-09 14:28:17 +08:00
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
func (s *e2eAccountService) Create(ctx context.Context, req *domain.CreateAccountRequest) (*domain.Account, error) {
|
|
|
|
|
|
return &domain.Account{ID: 1, SupplierID: req.SupplierID, Provider: req.Provider, AccountType: req.AccountType, Status: domain.AccountStatusActive}, nil
|
|
|
|
|
|
}
|
2026-04-09 14:28:17 +08:00
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
func (s *e2eAccountService) Activate(ctx context.Context, supplierID, accountID int64) (*domain.Account, error) {
|
|
|
|
|
|
return &domain.Account{ID: accountID, SupplierID: supplierID, Status: domain.AccountStatusActive}, nil
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
func (s *e2eAccountService) Suspend(ctx context.Context, supplierID, accountID int64) (*domain.Account, error) {
|
|
|
|
|
|
return &domain.Account{ID: accountID, SupplierID: supplierID, Status: domain.AccountStatusSuspended}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *e2eAccountService) Delete(ctx context.Context, supplierID, accountID int64) error {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *e2eAccountService) GetByID(ctx context.Context, supplierID, accountID int64) (*domain.Account, error) {
|
|
|
|
|
|
return &domain.Account{ID: accountID, SupplierID: supplierID, Status: domain.AccountStatusActive}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type e2ePackageService struct{}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *e2ePackageService) CreateDraft(ctx context.Context, supplierID int64, req *domain.CreatePackageDraftRequest) (*domain.Package, error) {
|
|
|
|
|
|
return &domain.Package{ID: 1, SupplierID: supplierID, Model: req.Model, Status: domain.PackageStatusDraft}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *e2ePackageService) Publish(ctx context.Context, supplierID, packageID int64) (*domain.Package, error) {
|
|
|
|
|
|
return &domain.Package{ID: packageID, SupplierID: supplierID, Status: domain.PackageStatusActive}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *e2ePackageService) Pause(ctx context.Context, supplierID, packageID int64) (*domain.Package, error) {
|
|
|
|
|
|
return &domain.Package{ID: packageID, SupplierID: supplierID, Status: domain.PackageStatusPaused}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *e2ePackageService) Unlist(ctx context.Context, supplierID, packageID int64) (*domain.Package, error) {
|
|
|
|
|
|
return &domain.Package{ID: packageID, SupplierID: supplierID, Status: domain.PackageStatusExpired}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *e2ePackageService) Clone(ctx context.Context, supplierID, packageID int64) (*domain.Package, error) {
|
|
|
|
|
|
return &domain.Package{ID: packageID + 1, SupplierID: supplierID, Status: domain.PackageStatusDraft}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *e2ePackageService) BatchUpdatePrice(ctx context.Context, supplierID int64, req *domain.BatchUpdatePriceRequest) (*domain.BatchUpdatePriceResponse, error) {
|
|
|
|
|
|
return &domain.BatchUpdatePriceResponse{Total: len(req.Items), SuccessCount: len(req.Items)}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *e2ePackageService) GetByID(ctx context.Context, supplierID, packageID int64) (*domain.Package, error) {
|
|
|
|
|
|
return &domain.Package{ID: packageID, SupplierID: supplierID, Status: domain.PackageStatusActive}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type e2eSettlementService struct{}
|
2026-04-09 14:28:17 +08:00
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
func (s *e2eSettlementService) Withdraw(ctx context.Context, supplierID int64, req *domain.WithdrawRequest) (*domain.Settlement, error) {
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
|
return &domain.Settlement{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
SupplierID: supplierID,
|
|
|
|
|
|
SettlementNo: "SET-001",
|
|
|
|
|
|
Status: domain.SettlementStatusPending,
|
|
|
|
|
|
TotalAmount: req.Amount,
|
|
|
|
|
|
NetAmount: req.Amount,
|
|
|
|
|
|
CreatedAt: now,
|
|
|
|
|
|
UpdatedAt: now,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *e2eSettlementService) Cancel(ctx context.Context, supplierID, settlementID int64) (*domain.Settlement, error) {
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
|
return &domain.Settlement{ID: settlementID, SupplierID: supplierID, Status: domain.SettlementStatusFailed, CreatedAt: now, UpdatedAt: now}, nil
|
|
|
|
|
|
}
|
2026-04-09 14:28:17 +08:00
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
func (s *e2eSettlementService) GetByID(ctx context.Context, supplierID, settlementID int64) (*domain.Settlement, error) {
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
|
return &domain.Settlement{ID: settlementID, SupplierID: supplierID, Status: domain.SettlementStatusPending, CreatedAt: now, UpdatedAt: now}, nil
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
func (s *e2eSettlementService) List(ctx context.Context, supplierID int64) ([]*domain.Settlement, error) {
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
|
return []*domain.Settlement{{ID: 1, SupplierID: supplierID, Status: domain.SettlementStatusPending, CreatedAt: now, UpdatedAt: now}}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 07:32:41 +08:00
|
|
|
|
func (s *e2eSettlementService) GetBillingSummary(ctx context.Context, supplierID int64, startDate, endDate string) (*domain.BillingSummary, error) {
|
|
|
|
|
|
return &domain.BillingSummary{
|
|
|
|
|
|
Period: domain.BillingPeriod{Start: startDate, End: endDate},
|
|
|
|
|
|
Summary: domain.BillingTotal{TotalRevenue: 100, TotalOrders: 1, TotalUsage: 1000, TotalRequests: 10, AvgSuccessRate: 1, NetEarnings: 95},
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
type e2eEarningService struct{}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *e2eEarningService) ListRecords(ctx context.Context, supplierID int64, startDate, endDate string, page, pageSize int) ([]*domain.EarningRecord, int, error) {
|
|
|
|
|
|
return []*domain.EarningRecord{
|
|
|
|
|
|
{ID: 1, SupplierID: supplierID, EarningsType: "usage", Amount: 100, Status: "available", EarnedAt: time.Now().UTC()},
|
|
|
|
|
|
}, 1, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *e2eEarningService) GetBillingSummary(ctx context.Context, supplierID int64, startDate, endDate string) (*domain.BillingSummary, error) {
|
|
|
|
|
|
return &domain.BillingSummary{
|
|
|
|
|
|
Period: domain.BillingPeriod{Start: startDate, End: endDate},
|
|
|
|
|
|
Summary: domain.BillingTotal{TotalRevenue: 100, TotalOrders: 1, TotalUsage: 1000, TotalRequests: 10, AvgSuccessRate: 1, NetEarnings: 95},
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type staticTokenBackend struct {
|
|
|
|
|
|
statusByTokenID map[string]string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (b *staticTokenBackend) CheckTokenStatus(ctx context.Context, tokenID string) (string, error) {
|
|
|
|
|
|
if status, ok := b.statusByTokenID[tokenID]; ok {
|
|
|
|
|
|
return status, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return "active", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func newE2ESystem(t *testing.T, opts e2eOptions) *e2eSystem {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
|
|
accountSvc := &e2eAccountService{
|
|
|
|
|
|
verifyResult: &domain.VerifyResult{
|
|
|
|
|
|
VerifyStatus: "pass",
|
|
|
|
|
|
AvailableQuota: 2048,
|
|
|
|
|
|
RiskScore: 0,
|
|
|
|
|
|
CheckItems: []domain.CheckItem{
|
|
|
|
|
|
{Item: "credential_format", Result: "pass", Message: "ok"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
auditStore := audit.NewMemoryAuditStore()
|
|
|
|
|
|
|
|
|
|
|
|
api := httpapi.NewSupplyAPI(
|
|
|
|
|
|
accountSvc,
|
|
|
|
|
|
&e2ePackageService{},
|
|
|
|
|
|
&e2eSettlementService{},
|
|
|
|
|
|
&e2eEarningService{},
|
|
|
|
|
|
nil,
|
|
|
|
|
|
auditStore,
|
|
|
|
|
|
nil,
|
|
|
|
|
|
0,
|
|
|
|
|
|
"https://statements.example.com",
|
|
|
|
|
|
func() time.Time { return time.Unix(1712800000, 0).UTC() },
|
|
|
|
|
|
)
|
|
|
|
|
|
api.SetWithdrawEnabled(opts.withdrawEnabled)
|
|
|
|
|
|
|
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
healthHandler := httpapi.NewHealthHandlerWithDefaults(nil, nil)
|
|
|
|
|
|
mux.HandleFunc("/actuator/health", healthHandler.ServeHealth)
|
|
|
|
|
|
mux.HandleFunc("/actuator/health/live", healthHandler.ServeLiveness)
|
|
|
|
|
|
mux.HandleFunc("/actuator/health/ready", healthHandler.ServeReadiness)
|
|
|
|
|
|
api.Register(mux)
|
|
|
|
|
|
|
|
|
|
|
|
authMiddleware := middleware.NewAuthMiddleware(
|
|
|
|
|
|
middleware.AuthConfig{
|
|
|
|
|
|
SecretKey: "e2e-secret-key-should-be-long",
|
|
|
|
|
|
Algorithm: "HS256",
|
|
|
|
|
|
Issuer: "supply-api-e2e",
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
middleware.NewTokenCache(),
|
|
|
|
|
|
&staticTokenBackend{statusByTokenID: map[string]string{}},
|
|
|
|
|
|
adapter.NewAuditEmitterAdapter(auditStore),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger := logging.NewLogger("supply-api-e2e", logging.LogLevelError)
|
|
|
|
|
|
|
|
|
|
|
|
var handler http.Handler = mux
|
|
|
|
|
|
handler = middleware.RequestID(handler)
|
|
|
|
|
|
handler = middleware.Recovery(handler)
|
|
|
|
|
|
handler = middleware.Logging(handler, logger)
|
|
|
|
|
|
handler = middleware.TracingMiddleware(handler)
|
|
|
|
|
|
handler = authMiddleware.TokenVerifyMiddleware(handler)
|
|
|
|
|
|
handler = authMiddleware.BearerExtractMiddleware(handler)
|
|
|
|
|
|
handler = authMiddleware.QueryKeyRejectMiddleware(handler)
|
|
|
|
|
|
|
|
|
|
|
|
return &e2eSystem{
|
|
|
|
|
|
handler: handler,
|
|
|
|
|
|
accountSvc: accountSvc,
|
|
|
|
|
|
auditStore: auditStore,
|
|
|
|
|
|
secretKey: "e2e-secret-key-should-be-long",
|
|
|
|
|
|
tokenIssuer: "supply-api-e2e",
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
2026-04-11 09:25:31 +08:00
|
|
|
|
}
|
2026-04-09 14:28:17 +08:00
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
func (s *e2eSystem) tokenForTenant(t *testing.T, tokenID string, tenantID int64) string {
|
|
|
|
|
|
t.Helper()
|
2026-04-09 14:28:17 +08:00
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
claims := &middleware.TokenClaims{
|
|
|
|
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
|
|
|
|
ID: tokenID,
|
|
|
|
|
|
Issuer: s.tokenIssuer,
|
|
|
|
|
|
Subject: "subject-42",
|
|
|
|
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
|
|
|
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
|
|
|
|
},
|
|
|
|
|
|
SubjectID: "42",
|
|
|
|
|
|
Role: "org_admin",
|
|
|
|
|
|
Scope: []string{"supply:write", "supply:read"},
|
|
|
|
|
|
TenantID: tenantID,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
|
|
|
|
tokenString, err := token.SignedString([]byte(s.secretKey))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("failed to sign token: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return tokenString
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
func decodeJSONBody(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
|
|
var payload map[string]any
|
|
|
|
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
|
|
|
|
|
t.Fatalf("failed to decode response: %v, body=%s", err, recorder.Body.String())
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
2026-04-11 09:25:31 +08:00
|
|
|
|
return payload
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_HealthProbe_IsPublicAndHealthy(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/actuator/health", nil)
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "health-req-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
2026-04-09 14:28:17 +08:00
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
if recorder.Header().Get("X-Request-Id") != "health-req-001" {
|
|
|
|
|
|
t.Fatalf("expected X-Request-Id response header to round-trip")
|
|
|
|
|
|
}
|
2026-04-09 14:28:17 +08:00
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
if payload["status"] != "healthy" {
|
|
|
|
|
|
t.Fatalf("expected healthy status, got %v", payload["status"])
|
|
|
|
|
|
}
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
func TestE2E_ProtectedRoute_RejectsMissingBearer(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/billing?start_date=2026-04-01&end_date=2026-04-11", nil)
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusUnauthorized {
|
|
|
|
|
|
t.Fatalf("expected status 401, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
errBody, ok := payload["error"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected error body, got %v", payload["error"])
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
2026-04-11 09:25:31 +08:00
|
|
|
|
if errBody["code"] != "AUTH_MISSING_BEARER" {
|
|
|
|
|
|
t.Fatalf("expected AUTH_MISSING_BEARER, got %v", errBody["code"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_ProtectedRoute_RejectsQueryCredentialLeak(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
2026-04-09 14:28:17 +08:00
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/billing?api_key=abcdefghijklmnopqrstuvwxyz123456", nil)
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusUnauthorized {
|
|
|
|
|
|
t.Fatalf("expected status 401, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
2026-04-09 14:28:17 +08:00
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
errBody, ok := payload["error"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected error body, got %v", payload["error"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if errBody["code"] != "QUERY_KEY_NOT_ALLOWED" {
|
|
|
|
|
|
t.Fatalf("expected QUERY_KEY_NOT_ALLOWED, got %v", errBody["code"])
|
|
|
|
|
|
}
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
func TestE2E_VerifyAccount_UsesTenantIDFromVerifiedToken(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-e2e-verify", 2001)
|
|
|
|
|
|
|
|
|
|
|
|
body := `{"provider":"openai","account_type":"resource","credential_input":"sk-test"}`
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/verify", strings.NewReader(body))
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "verify-req-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
if system.accountSvc.lastVerifySupplierID != 2001 {
|
|
|
|
|
|
t.Fatalf("expected supplierID 2001 from token tenant, got %d", system.accountSvc.lastVerifySupplierID)
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
if payload["request_id"] != "verify-req-001" {
|
|
|
|
|
|
t.Fatalf("expected request_id verify-req-001, got %v", payload["request_id"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Withdraw_DisabledBeforeSMSIntegration(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-e2e-withdraw", 2002)
|
|
|
|
|
|
|
|
|
|
|
|
body := `{"withdraw_amount":100,"payment_method":"bank","payment_account":"13800000000","sms_code":"123456"}`
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/settlements/withdraw", strings.NewReader(body))
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusServiceUnavailable {
|
|
|
|
|
|
t.Fatalf("expected status 503, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
2026-04-09 14:28:17 +08:00
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
errBody, ok := payload["error"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected error body, got %v", payload["error"])
|
|
|
|
|
|
}
|
2026-04-13 07:32:41 +08:00
|
|
|
|
// 验证功能禁用错误码(SUP_HTTP_5030 = FEATURE_DISABLED)
|
|
|
|
|
|
if errBody["code"] != httpapi.CodeFeatureDisabled {
|
|
|
|
|
|
t.Fatalf("expected %s, got %v", httpapi.CodeFeatureDisabled, errBody["code"])
|
2026-04-11 09:25:31 +08:00
|
|
|
|
}
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
func TestE2E_AuditEvent_CanBeReadBackThroughAPI(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-e2e-audit", 3001)
|
|
|
|
|
|
|
|
|
|
|
|
if err := system.auditStore.Emit(context.Background(), audit.Event{
|
|
|
|
|
|
TenantID: 3001,
|
|
|
|
|
|
ObjectType: "supply_account",
|
|
|
|
|
|
ObjectID: 77,
|
|
|
|
|
|
Action: "verify",
|
|
|
|
|
|
RequestID: "audit-req-001",
|
|
|
|
|
|
ResultCode: "OK",
|
|
|
|
|
|
SourceIP: "127.0.0.1",
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
t.Fatalf("failed to seed audit event: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
events, err := system.auditStore.Query(context.Background(), audit.EventFilter{TenantID: 3001, Limit: 1})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("failed to query seeded audit event: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(events) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 audit event, got %d", len(events))
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/events/"+events[0].EventID, nil)
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "audit-read-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
2026-04-09 14:28:17 +08:00
|
|
|
|
|
2026-04-11 09:25:31 +08:00
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
data, ok := payload["data"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected data object, got %v", payload["data"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if data["event_id"] != events[0].EventID {
|
|
|
|
|
|
t.Fatalf("expected event_id %s, got %v", events[0].EventID, data["event_id"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if data["request_id"] != "audit-req-001" {
|
|
|
|
|
|
t.Fatalf("expected seeded request_id audit-req-001, got %v", data["request_id"])
|
|
|
|
|
|
}
|
2026-04-09 14:28:17 +08:00
|
|
|
|
}
|
2026-04-13 18:53:35 +08:00
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 增强 E2E 测试:账户管理流程
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Account_Create_Success(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-account-create", 4001)
|
|
|
|
|
|
|
|
|
|
|
|
body := `{"provider":"openai","account_type":"resource","credential_input":"sk-test12345678901234567890"}`
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(body))
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "account-create-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusCreated {
|
|
|
|
|
|
t.Fatalf("expected status 201, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
data, ok := payload["data"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected data object, got %v", payload["data"])
|
|
|
|
|
|
}
|
|
|
|
|
|
// 验证账户创建成功(mock 服务返回固定的 account_id)
|
|
|
|
|
|
if data["account_id"] == nil || data["account_id"].(float64) != 1 {
|
|
|
|
|
|
t.Fatalf("expected account_id 1, got %v", data["account_id"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Account_Activate_Success(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-account-activate", 4002)
|
|
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/123/activate", nil)
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "account-activate-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
data, ok := payload["data"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected data object, got %v", payload["data"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if data["status"] != "active" {
|
|
|
|
|
|
t.Fatalf("expected status active, got %v", data["status"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Account_Suspend_Success(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-account-suspend", 4003)
|
|
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/456/suspend", nil)
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "account-suspend-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
data, ok := payload["data"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected data object, got %v", payload["data"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if data["status"] != "suspended" {
|
|
|
|
|
|
t.Fatalf("expected status suspended, got %v", data["status"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Account_Delete_Success(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-account-delete", 4004)
|
|
|
|
|
|
|
|
|
|
|
|
// 删除账户的正确路由: DELETE /api/v1/supply/accounts/{id}/delete
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/supply/accounts/789/delete", nil)
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "account-delete-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusNoContent {
|
|
|
|
|
|
t.Fatalf("expected status 204, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 增强 E2E 测试:套餐管理流程
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Package_CreateDraft_Success(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-package-create", 5001)
|
|
|
|
|
|
|
|
|
|
|
|
body := `{"model":"gpt-4-turbo","display_name":"GPT-4 Turbo","price_input":0.03,"quota_per_batch":1000}`
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/draft", strings.NewReader(body))
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "package-create-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusCreated {
|
|
|
|
|
|
t.Fatalf("expected status 201, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
data, ok := payload["data"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected data object, got %v", payload["data"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if data["status"] != "draft" {
|
|
|
|
|
|
t.Fatalf("expected status draft, got %v", data["status"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Package_Publish_Success(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-package-publish", 5002)
|
|
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/1001/publish", nil)
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "package-publish-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
data, ok := payload["data"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected data object, got %v", payload["data"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if data["status"] != "active" {
|
|
|
|
|
|
t.Fatalf("expected status active, got %v", data["status"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Package_Pause_Success(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-package-pause", 5003)
|
|
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/1002/pause", nil)
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "package-pause-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
data, ok := payload["data"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected data object, got %v", payload["data"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if data["status"] != "paused" {
|
|
|
|
|
|
t.Fatalf("expected status paused, got %v", data["status"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 增强 E2E 测试:结算与账单
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Settlement_List_Success(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-settlement-list", 6001)
|
|
|
|
|
|
|
|
|
|
|
|
// 结算列表通过账单接口获取: /api/v1/supply/billing
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/billing?start_date=2026-04-01&end_date=2026-04-30", nil)
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "settlement-list-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
data, ok := payload["data"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected data object, got %v", payload["data"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if data["summary"] == nil {
|
|
|
|
|
|
t.Fatalf("expected summary field in billing data, got %v", data)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Billing_GetSummary_Success(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-billing-summary", 6002)
|
|
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/billing?start_date=2026-04-01&end_date=2026-04-30", nil)
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "billing-summary-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
data, ok := payload["data"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected data object, got %v", payload["data"])
|
|
|
|
|
|
}
|
|
|
|
|
|
summary, ok := data["summary"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected summary object, got %v", data["summary"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if summary["total_revenue"] == nil {
|
|
|
|
|
|
t.Fatalf("expected total_revenue field, got %v", summary)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 增强 E2E 测试:收益记录
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Earnings_ListRecords_Success(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-earnings-list", 7001)
|
|
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/earnings/records?start_date=2026-04-01&end_date=2026-04-30&page=1&page_size=10", nil)
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "earnings-list-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
// earnings/records 直接返回数组
|
|
|
|
|
|
data, ok := payload["data"].([]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected data array, got %v", payload["data"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(data) == 0 {
|
|
|
|
|
|
t.Fatalf("expected at least 1 earning record, got 0")
|
|
|
|
|
|
}
|
|
|
|
|
|
firstRecord, ok := data[0].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected first record to be map, got %v", data[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
if firstRecord["amount"] == nil {
|
|
|
|
|
|
t.Fatalf("expected amount field in record, got %v", firstRecord)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 增强 E2E 测试:错误处理
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Error_InvalidJSON(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-invalid-json", 8001)
|
|
|
|
|
|
|
|
|
|
|
|
body := `{"provider":"openai", invalid json}`
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(body))
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusBadRequest {
|
|
|
|
|
|
t.Fatalf("expected status 400, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
errBody, ok := payload["error"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected error object, got %v", payload["error"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if errBody["code"] != httpapi.CodeBadRequest {
|
|
|
|
|
|
t.Fatalf("expected %s, got %v", httpapi.CodeBadRequest, errBody["code"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Error_EmptyBody(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-empty-body", 8002)
|
|
|
|
|
|
|
|
|
|
|
|
// 空请求体
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(""))
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusBadRequest {
|
|
|
|
|
|
t.Fatalf("expected status 400, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Error_ExpiredToken(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
|
|
|
|
|
|
// 创建一个已过期的 token
|
|
|
|
|
|
claims := &middleware.TokenClaims{
|
|
|
|
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
|
|
|
|
ID: "tok-expired",
|
|
|
|
|
|
Issuer: system.tokenIssuer,
|
|
|
|
|
|
Subject: "subject-42",
|
|
|
|
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), // 1小时前过期
|
|
|
|
|
|
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
|
|
|
|
|
|
},
|
|
|
|
|
|
SubjectID: "42",
|
|
|
|
|
|
Role: "org_admin",
|
|
|
|
|
|
Scope: []string{"supply:write", "supply:read"},
|
|
|
|
|
|
TenantID: 8003,
|
|
|
|
|
|
}
|
|
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
|
|
|
|
tokenString, _ := token.SignedString([]byte(system.secretKey))
|
|
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts", nil)
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+tokenString)
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusUnauthorized {
|
|
|
|
|
|
t.Fatalf("expected status 401, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Error_InvalidPathAccount(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-invalid-path", 8004)
|
|
|
|
|
|
|
|
|
|
|
|
// 无效的账户 ID
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts/invalid-id", nil)
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusNotFound {
|
|
|
|
|
|
t.Fatalf("expected status 404, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := decodeJSONBody(t, recorder)
|
|
|
|
|
|
errBody, ok := payload["error"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected error object, got %v", payload["error"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if errBody["code"] != httpapi.CodeNotFound {
|
|
|
|
|
|
t.Fatalf("expected %s, got %v", httpapi.CodeNotFound, errBody["code"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 增强 E2E 测试:审计日志敏感数据脱敏
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_AuditEvent_SensitiveDataSanitized(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-audit-sanitize", 9001)
|
|
|
|
|
|
|
|
|
|
|
|
// 触发一个包含敏感信息的操作
|
|
|
|
|
|
body := `{"provider":"openai","account_type":"resource","credential_input":"sk-1234567890abcdef"}`
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/verify", strings.NewReader(body))
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "audit-sanitize-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
// 查询审计事件
|
|
|
|
|
|
events, err := system.auditStore.Query(context.Background(), audit.EventFilter{
|
|
|
|
|
|
TenantID: 9001,
|
|
|
|
|
|
ObjectType: "supply_account",
|
|
|
|
|
|
Action: "verify",
|
|
|
|
|
|
Limit: 1,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("failed to query audit events: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证事件包含敏感信息(应该脱敏)
|
|
|
|
|
|
for _, event := range events {
|
|
|
|
|
|
afterStateStr, ok := event.AfterState["credential_input"]
|
|
|
|
|
|
if ok {
|
|
|
|
|
|
// 验证凭证已脱敏
|
|
|
|
|
|
credStr, ok := afterStateStr.(string)
|
|
|
|
|
|
if ok && (credStr == "sk-1234567890abcdef" || strings.Contains(credStr, "sk-")) {
|
|
|
|
|
|
t.Fatalf("credential_input should be sanitized, got %v", afterStateStr)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 增强 E2E 测试:W3C Trace Context 追踪
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_Tracing_W3CTraceContext(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-trace", 10001)
|
|
|
|
|
|
|
|
|
|
|
|
// 模拟 W3C Trace Context 头
|
|
|
|
|
|
// 格式: 00-{trace-id}-{span-id}-{trace-flags}
|
|
|
|
|
|
traceParent := "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 POST /api/v1/supply/accounts/verify 来测试追踪
|
|
|
|
|
|
body := `{"provider":"openai","account_type":"resource","credential_input":"sk-test"}`
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/verify", strings.NewReader(body))
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("traceparent", traceParent)
|
|
|
|
|
|
req.Header.Set("X-Request-Id", "trace-001")
|
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder, req)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证追踪中间件正确处理了 traceparent 头
|
|
|
|
|
|
// 检查 X-Request-Id 是否正确传递
|
|
|
|
|
|
if recorder.Header().Get("X-Request-Id") != "trace-001" {
|
|
|
|
|
|
t.Fatalf("expected X-Request-Id trace-001, got %s", recorder.Header().Get("X-Request-Id"))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 增强 E2E 测试:并发请求处理
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestE2E_ConcurrentRequests_SameIdempotencyKey(t *testing.T) {
|
|
|
|
|
|
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
|
|
|
|
|
|
token := system.tokenForTenant(t, "tok-idempotent", 11001)
|
|
|
|
|
|
|
|
|
|
|
|
// 使用相同的 Idempotency-Key 发送多个请求
|
|
|
|
|
|
idempotencyKey := "idem-key-12345"
|
|
|
|
|
|
body := `{"provider":"openai","account_type":"resource","credential_input":"sk-concurrent-test"}`
|
|
|
|
|
|
|
|
|
|
|
|
// 发送第一个请求
|
|
|
|
|
|
req1 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(body))
|
|
|
|
|
|
req1.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req1.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req1.Header.Set("Idempotency-Key", idempotencyKey)
|
|
|
|
|
|
req1.Header.Set("X-Request-Id", "idem-req-001")
|
|
|
|
|
|
recorder1 := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder1, req1)
|
|
|
|
|
|
|
|
|
|
|
|
if recorder1.Code != http.StatusCreated {
|
|
|
|
|
|
t.Fatalf("first request failed: expected 201, got %d, body=%s", recorder1.Code, recorder1.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 发送第二个相同 Idempotency-Key 的请求
|
|
|
|
|
|
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(body))
|
|
|
|
|
|
req2.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req2.Header.Set("Idempotency-Key", idempotencyKey)
|
|
|
|
|
|
req2.Header.Set("X-Request-Id", "idem-req-002")
|
|
|
|
|
|
recorder2 := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
|
|
system.handler.ServeHTTP(recorder2, req2)
|
|
|
|
|
|
|
|
|
|
|
|
// 第二个请求应该返回相同的响应(幂等性)
|
|
|
|
|
|
if recorder2.Code != http.StatusCreated {
|
|
|
|
|
|
t.Fatalf("second request failed: expected 201, got %d, body=%s", recorder2.Code, recorder2.Body.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证响应一致
|
|
|
|
|
|
payload1 := decodeJSONBody(t, recorder1)
|
|
|
|
|
|
payload2 := decodeJSONBody(t, recorder2)
|
|
|
|
|
|
|
|
|
|
|
|
if payload1["request_id"] == payload2["request_id"] {
|
|
|
|
|
|
t.Log("Idempotent requests returned same request_id (expected behavior)")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|