基于 backend-code-audit 审计报告,修复剩余 P0/P1/P2 共 34 项问题: P0 生产 Bug: - 修复 time.Since(time.Now()) 计时逻辑错误 (P0-03) - generateRandomID 改用 crypto/rand 替代固定索引 (P0-04) - IncrementQuotaUsed 重写为 Ent 原子操作消除 TOCTOU 竞态 (P0-05) 安全加固: - gateway/openai handler 错误响应替换为泛化消息,防止内部信息泄露 (P1-14) - usage_log_repo dateFormat 参数改用白名单映射,防止 SQL 注入 (P1-16) - 默认配置安全加固:sslmode=prefer、response_headers=true、mode=release (P1-18/19, P2-15) 性能优化: - gateway handler 循环内 defer 替换为显式 releaseWait 闭包 (P1-02) - group_repo/promo_code_repo Count 前 Clone 查询避免状态污染 (P1-03) - usage_log_repo 四个查询添加 LIMIT 10000 防止 OOM (P1-07) - GetBatchUsageStats 添加时间范围参数,默认最近 30 天 (P1-10) - ip.go CIDR 预编译为包级变量 (P1-11) - BatchUpdateCredentials 重构为先验证后更新 (P1-13) 缓存一致性: - billing_cache 添加 jitteredTTL 防止缓存雪崩 (P2-10) - DeductUserBalance/UpdateSubscriptionUsage 错误传播修复 (P2-12) - UserService.UpdateBalance 成功后异步失效 billingCache (P2-13) 代码质量: - search 截断改为按 rune 处理,支持多字节字符 (P2-01) - TLS Handshake 改为 HandshakeContext 支持 context 取消 (P2-07) - CORS 预检添加 Access-Control-Max-Age: 86400 (P2-16) 测试覆盖: - 新增 user_service_test.go(UpdateBalance 缓存失效 6 个用例) - 新增 batch_update_credentials_test.go(fail-fast + 类型验证 7 个用例) - 新增 response_transformer_test.go、ip_test.go、usage_log_repo_unit_test.go、search_truncate_test.go - 集成测试:IncrementQuotaUsed 并发测试、billing_cache 错误传播测试 - config_test.go 补充 server.mode/sslmode 默认值断言 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
201 lines
6.6 KiB
Go
201 lines
6.6 KiB
Go
//go:build unit
|
||
|
||
package admin
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"sync/atomic"
|
||
"testing"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
)
|
||
|
||
// failingAdminService 嵌入 stubAdminService,可配置 UpdateAccount 在指定 ID 时失败。
|
||
type failingAdminService struct {
|
||
*stubAdminService
|
||
failOnAccountID int64
|
||
updateCallCount atomic.Int64
|
||
}
|
||
|
||
func (f *failingAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
|
||
f.updateCallCount.Add(1)
|
||
if id == f.failOnAccountID {
|
||
return nil, errors.New("database error")
|
||
}
|
||
return f.stubAdminService.UpdateAccount(ctx, id, input)
|
||
}
|
||
|
||
func setupAccountHandlerWithService(adminSvc service.AdminService) (*gin.Engine, *AccountHandler) {
|
||
gin.SetMode(gin.TestMode)
|
||
router := gin.New()
|
||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||
router.POST("/api/v1/admin/accounts/batch-update-credentials", handler.BatchUpdateCredentials)
|
||
return router, handler
|
||
}
|
||
|
||
func TestBatchUpdateCredentials_AllSuccess(t *testing.T) {
|
||
svc := &failingAdminService{stubAdminService: newStubAdminService()}
|
||
router, _ := setupAccountHandlerWithService(svc)
|
||
|
||
body, _ := json.Marshal(BatchUpdateCredentialsRequest{
|
||
AccountIDs: []int64{1, 2, 3},
|
||
Field: "account_uuid",
|
||
Value: "test-uuid",
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req, _ := http.NewRequest("POST", "/api/v1/admin/accounts/batch-update-credentials", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
router.ServeHTTP(w, req)
|
||
|
||
require.Equal(t, http.StatusOK, w.Code, "全部成功时应返回 200")
|
||
require.Equal(t, int64(3), svc.updateCallCount.Load(), "应调用 3 次 UpdateAccount")
|
||
}
|
||
|
||
func TestBatchUpdateCredentials_FailFast(t *testing.T) {
|
||
// 让第 2 个账号(ID=2)更新时失败
|
||
svc := &failingAdminService{
|
||
stubAdminService: newStubAdminService(),
|
||
failOnAccountID: 2,
|
||
}
|
||
router, _ := setupAccountHandlerWithService(svc)
|
||
|
||
body, _ := json.Marshal(BatchUpdateCredentialsRequest{
|
||
AccountIDs: []int64{1, 2, 3},
|
||
Field: "org_uuid",
|
||
Value: "test-org",
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req, _ := http.NewRequest("POST", "/api/v1/admin/accounts/batch-update-credentials", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
router.ServeHTTP(w, req)
|
||
|
||
require.Equal(t, http.StatusInternalServerError, w.Code, "ID=2 失败时应返回 500")
|
||
// 验证 fail-fast:ID=1 更新成功,ID=2 失败,ID=3 不应被调用
|
||
require.Equal(t, int64(2), svc.updateCallCount.Load(),
|
||
"fail-fast: 应只调用 2 次 UpdateAccount(ID=1 成功、ID=2 失败后停止)")
|
||
}
|
||
|
||
func TestBatchUpdateCredentials_FirstAccountNotFound(t *testing.T) {
|
||
// GetAccount 在 stubAdminService 中总是成功的,需要创建一个 GetAccount 会失败的 stub
|
||
svc := &getAccountFailingService{
|
||
stubAdminService: newStubAdminService(),
|
||
failOnAccountID: 1,
|
||
}
|
||
router, _ := setupAccountHandlerWithService(svc)
|
||
|
||
body, _ := json.Marshal(BatchUpdateCredentialsRequest{
|
||
AccountIDs: []int64{1, 2, 3},
|
||
Field: "account_uuid",
|
||
Value: "test",
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req, _ := http.NewRequest("POST", "/api/v1/admin/accounts/batch-update-credentials", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
router.ServeHTTP(w, req)
|
||
|
||
require.Equal(t, http.StatusNotFound, w.Code, "第一阶段验证失败应返回 404")
|
||
}
|
||
|
||
// getAccountFailingService 模拟 GetAccount 在特定 ID 时返回 not found。
|
||
type getAccountFailingService struct {
|
||
*stubAdminService
|
||
failOnAccountID int64
|
||
}
|
||
|
||
func (f *getAccountFailingService) GetAccount(ctx context.Context, id int64) (*service.Account, error) {
|
||
if id == f.failOnAccountID {
|
||
return nil, errors.New("not found")
|
||
}
|
||
return f.stubAdminService.GetAccount(ctx, id)
|
||
}
|
||
|
||
func TestBatchUpdateCredentials_InterceptWarmupRequests_NonBool(t *testing.T) {
|
||
svc := &failingAdminService{stubAdminService: newStubAdminService()}
|
||
router, _ := setupAccountHandlerWithService(svc)
|
||
|
||
// intercept_warmup_requests 传入非 bool 类型(string),应返回 400
|
||
body, _ := json.Marshal(map[string]any{
|
||
"account_ids": []int64{1},
|
||
"field": "intercept_warmup_requests",
|
||
"value": "not-a-bool",
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req, _ := http.NewRequest("POST", "/api/v1/admin/accounts/batch-update-credentials", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
router.ServeHTTP(w, req)
|
||
|
||
require.Equal(t, http.StatusBadRequest, w.Code,
|
||
"intercept_warmup_requests 传入非 bool 值应返回 400")
|
||
}
|
||
|
||
func TestBatchUpdateCredentials_InterceptWarmupRequests_ValidBool(t *testing.T) {
|
||
svc := &failingAdminService{stubAdminService: newStubAdminService()}
|
||
router, _ := setupAccountHandlerWithService(svc)
|
||
|
||
body, _ := json.Marshal(map[string]any{
|
||
"account_ids": []int64{1},
|
||
"field": "intercept_warmup_requests",
|
||
"value": true,
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req, _ := http.NewRequest("POST", "/api/v1/admin/accounts/batch-update-credentials", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
router.ServeHTTP(w, req)
|
||
|
||
require.Equal(t, http.StatusOK, w.Code,
|
||
"intercept_warmup_requests 传入合法 bool 值应返回 200")
|
||
}
|
||
|
||
func TestBatchUpdateCredentials_AccountUUID_NonString(t *testing.T) {
|
||
svc := &failingAdminService{stubAdminService: newStubAdminService()}
|
||
router, _ := setupAccountHandlerWithService(svc)
|
||
|
||
// account_uuid 传入非 string 类型(number),应返回 400
|
||
body, _ := json.Marshal(map[string]any{
|
||
"account_ids": []int64{1},
|
||
"field": "account_uuid",
|
||
"value": 12345,
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req, _ := http.NewRequest("POST", "/api/v1/admin/accounts/batch-update-credentials", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
router.ServeHTTP(w, req)
|
||
|
||
require.Equal(t, http.StatusBadRequest, w.Code,
|
||
"account_uuid 传入非 string 值应返回 400")
|
||
}
|
||
|
||
func TestBatchUpdateCredentials_AccountUUID_NullValue(t *testing.T) {
|
||
svc := &failingAdminService{stubAdminService: newStubAdminService()}
|
||
router, _ := setupAccountHandlerWithService(svc)
|
||
|
||
// account_uuid 传入 null(设置为空),应正常通过
|
||
body, _ := json.Marshal(map[string]any{
|
||
"account_ids": []int64{1},
|
||
"field": "account_uuid",
|
||
"value": nil,
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req, _ := http.NewRequest("POST", "/api/v1/admin/accounts/batch-update-credentials", bytes.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
router.ServeHTTP(w, req)
|
||
|
||
require.Equal(t, http.StatusOK, w.Code,
|
||
"account_uuid 传入 null 应返回 200")
|
||
}
|