修复店铺列表参数校验与企业权限

This commit is contained in:
2026-07-22 13:13:38 +09:00
parent d4d6e91256
commit 751cb46079
10 changed files with 545 additions and 58 deletions

View File

@@ -0,0 +1,357 @@
package routes
import (
"context"
"io"
"net/http"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/bytedance/sonic"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/database"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
)
type shopTestResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data shopTestPageData `json:"data"`
}
type shopTestPageData struct {
Items []map[string]any `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
type shopTestEnv struct {
app *fiber.App
db *gorm.DB
redis *redis.Client
tokenManager *auth.TokenManager
}
func TestShopListValidatesAllQueryParameters(t *testing.T) {
env := newShopTestEnv(t)
token := env.newToken(t, auth.TokenInfo{
UserID: uniqueTestID(),
UserType: constants.UserTypeSuperAdmin,
Username: "ur60-super-admin",
})
testCases := []struct {
name string
query string
}{
{name: "页码小于一", query: "page=0"},
{name: "每页数量小于一", query: "page_size=0"},
{name: "每页数量超过上限", query: "page_size=101"},
{name: "店铺层级非法", query: "level=8"},
{name: "店铺状态非法", query: "status=2"},
{name: "店铺名称过长", query: "shop_name=" + strings.Repeat("店", 101)},
{name: "店铺编号过长", query: "shop_code=" + strings.Repeat("A", 51)},
{name: "参数解析失败", query: "parent_id=invalid"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
status, body := env.request(t, http.MethodGet, "/api/admin/shops?"+testCase.query, token)
if status != http.StatusBadRequest {
t.Fatalf("期望 HTTP 400实际为 %d响应%s", status, body)
}
var response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析响应失败:%v", err)
}
if response.Code != errors.CodeInvalidParam || response.Msg != "参数验证失败" || response.Data != nil {
t.Fatalf("参数错误响应不符合契约:%s", body)
}
if !strings.Contains(string(body), "timestamp") {
t.Fatalf("参数错误响应缺少时间戳:%s", body)
}
if strings.Contains(string(body), "validation") || strings.Contains(string(body), "strconv") {
t.Fatalf("参数错误响应泄露底层细节:%s", body)
}
})
}
}
func TestShopListUsesNormalizedAndExplicitPagination(t *testing.T) {
env := newShopTestEnv(t)
token := env.newToken(t, auth.TokenInfo{
UserID: uniqueTestID(),
UserType: constants.UserTypeSuperAdmin,
Username: "ur60-super-admin",
})
testCases := []struct {
name string
query string
expectedPage int
expectedSize int
}{
{name: "默认分页", expectedPage: 1, expectedSize: constants.DefaultPageSize},
{name: "显式分页", query: "?page=2&page_size=7&shop_name=ur60-no-match", expectedPage: 2, expectedSize: 7},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
status, body := env.request(t, http.MethodGet, "/api/admin/shops"+testCase.query, token)
if status != http.StatusOK {
t.Fatalf("期望 HTTP 200实际为 %d响应%s", status, body)
}
var response shopTestResponse
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析响应失败:%v", err)
}
if response.Code != errors.CodeSuccess || response.Data.Page != testCase.expectedPage || response.Data.Size != testCase.expectedSize {
t.Fatalf("分页响应不符合契约:%s", body)
}
})
}
}
func TestShopListKeepsAuthenticationContract(t *testing.T) {
env := newShopTestEnv(t)
testCases := []struct {
name string
token string
}{
{name: "未认证"},
{name: "无效令牌", token: "ur60-invalid-token"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
status, body := env.request(t, http.MethodGet, "/api/admin/shops", testCase.token)
if status != http.StatusUnauthorized {
t.Fatalf("期望 HTTP 401实际为 %d响应%s", status, body)
}
if !strings.Contains(string(body), "timestamp") {
t.Fatalf("认证错误响应缺少时间戳:%s", body)
}
})
}
}
func TestEnterpriseCannotAccessCoreShopManagementRoutes(t *testing.T) {
env := newShopTestEnv(t)
token := env.newToken(t, auth.TokenInfo{
UserID: uniqueTestID(),
UserType: constants.UserTypeEnterprise,
EnterpriseID: uniqueTestID(),
Username: "ur60-enterprise",
})
testCases := []struct {
name string
method string
path string
}{
{name: "店铺列表", method: http.MethodGet, path: "/api/admin/shops"},
{name: "创建店铺", method: http.MethodPost, path: "/api/admin/shops"},
{name: "更新店铺", method: http.MethodPut, path: "/api/admin/shops/1"},
{name: "删除店铺", method: http.MethodDelete, path: "/api/admin/shops/1"},
{name: "联级查询", method: http.MethodGet, path: "/api/admin/shops/cascade"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
status, body := env.request(t, testCase.method, testCase.path, token)
if status != http.StatusForbidden {
t.Fatalf("期望 HTTP 403实际为 %d响应%s", status, body)
}
var response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析响应失败:%v", err)
}
if response.Code != errors.CodeForbidden || response.Msg != "无权限访问店铺管理功能" || response.Data != nil {
t.Fatalf("企业账号禁止响应不符合契约:%s", body)
}
if !strings.Contains(string(body), "timestamp") {
t.Fatalf("企业账号禁止响应缺少时间戳:%s", body)
}
})
}
}
func TestCoreShopRestrictionDoesNotAffectOtherShopRoutes(t *testing.T) {
env := newShopTestEnv(t)
token := env.newToken(t, auth.TokenInfo{
UserID: uniqueTestID(),
UserType: constants.UserTypeEnterprise,
EnterpriseID: uniqueTestID(),
Username: "ur60-enterprise",
})
status, body := env.request(t, http.MethodGet, "/api/admin/shops/1/roles", token)
if status == http.StatusForbidden && strings.Contains(string(body), "无权限访问店铺管理功能") {
t.Fatalf("核心店铺管理拦截误伤店铺角色路由:%s", body)
}
}
func TestNonEnterpriseAccountsKeepCoreShopListAccess(t *testing.T) {
env := newShopTestEnv(t)
testCases := []struct {
name string
userType int
shopID uint
}{
{name: "超级管理员", userType: constants.UserTypeSuperAdmin},
{name: "平台账号", userType: constants.UserTypePlatform},
{name: "代理账号", userType: constants.UserTypeAgent, shopID: 1},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
token := env.newToken(t, auth.TokenInfo{
UserID: uniqueTestID(),
UserType: testCase.userType,
ShopID: testCase.shopID,
Username: "ur60-allowed-user",
})
status, body := env.request(t, http.MethodGet, "/api/admin/shops?page=1&page_size=1", token)
if status != http.StatusOK {
t.Fatalf("期望保留列表访问能力,实际 HTTP %d响应%s", status, body)
}
})
}
}
func newShopTestEnv(t *testing.T) *shopTestEnv {
t.Helper()
if os.Getenv("JUNHONG_DATABASE_HOST") == "" || os.Getenv("JUNHONG_REDIS_ADDRESS") == "" {
t.Skip("未加载 .env.local跳过依赖真实 PostgreSQL 和 Redis 的店铺 HTTP 集成测试")
}
cfg, err := config.Load()
if err != nil {
t.Fatalf("加载测试配置失败:%v", err)
}
logger := zap.NewNop()
db, err := database.InitPostgreSQL(&cfg.Database, logger)
if err != nil {
t.Fatalf("连接 PostgreSQL 失败:%v", err)
}
redisClient, err := database.NewRedisClient(database.RedisConfig{
Address: cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port),
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
PoolSize: cfg.Redis.PoolSize,
MinIdleConns: cfg.Redis.MinIdleConns,
DialTimeout: cfg.Redis.DialTimeout,
ReadTimeout: cfg.Redis.ReadTimeout,
WriteTimeout: cfg.Redis.WriteTimeout,
}, logger)
if err != nil {
t.Fatalf("连接 Redis 失败:%v", err)
}
t.Cleanup(func() {
_ = redisClient.Close()
if sqlDB, dbErr := db.DB(); dbErr == nil {
_ = sqlDB.Close()
}
})
shopStore := postgres.NewShopStore(db, redisClient)
service := shopService.New(shopStore, nil, nil, nil, nil, nil)
tokenManager := auth.NewTokenManager(redisClient, cfg.JWT.AccessTokenTTL, cfg.JWT.RefreshTokenTTL)
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{
TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
info, validateErr := tokenManager.ValidateAccessToken(context.Background(), token)
if validateErr != nil {
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
}
return &pkgMiddleware.UserContextInfo{
UserID: info.UserID,
UserType: info.UserType,
Username: info.Username,
ShopID: info.ShopID,
EnterpriseID: info.EnterpriseID,
}, nil
},
ShopStore: shopStore,
})
app := fiber.New(fiber.Config{
JSONEncoder: sonic.Marshal,
JSONDecoder: sonic.Unmarshal,
ErrorHandler: internalMiddleware.ErrorHandler(logger),
})
RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{
Shop: admin.NewShopHandler(service, validator.New()),
ShopRole: admin.NewShopRoleHandler(service),
}, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin")
return &shopTestEnv{app: app, db: db, redis: redisClient, tokenManager: tokenManager}
}
func (e *shopTestEnv) newToken(t *testing.T, info auth.TokenInfo) string {
t.Helper()
accessToken, refreshToken, err := e.tokenManager.GenerateTokenPair(context.Background(), &info)
if err != nil {
t.Fatalf("创建测试令牌失败:%v", err)
}
t.Cleanup(func() {
_ = e.tokenManager.RevokeToken(context.Background(), accessToken)
_ = e.tokenManager.RevokeToken(context.Background(), refreshToken)
_ = e.redis.Del(context.Background(), constants.RedisUserTokensKey(info.UserID)).Err()
})
return accessToken
}
func (e *shopTestEnv) request(t *testing.T, method, path, token string) (int, []byte) {
t.Helper()
request, err := http.NewRequest(method, path, nil)
if err != nil {
t.Fatalf("创建 HTTP 请求失败:%v", err)
}
if token != "" {
request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
}
response, err := e.app.Test(request, -1)
if err != nil {
t.Fatalf("执行 HTTP 请求失败:%v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("读取 HTTP 响应失败:%v", err)
}
return response.StatusCode, body
}
func uniqueTestID() uint {
return uint(time.Now().UnixNano() % 1_000_000_000)
}