fix: close refactor audit findings
This commit is contained in:
1 parent
3da93ba7ed
commit
1581d98692
16 files changed
+640
-81
No files matched your search
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/dujiao-next/internal/logger"
|
||||
adminapplication "github.com/dujiao-next/internal/modules/identity/admin/application"
|
||||
adminstore "github.com/dujiao-next/internal/modules/identity/admin/infrastructure/gormstore"
|
||||
resellerstore "github.com/dujiao-next/internal/modules/reseller/infrastructure/gormstore"
|
||||
"github.com/dujiao-next/internal/platform/database/gormdb"
|
||||
"github.com/dujiao-next/internal/shared/passwordpolicy"
|
||||
"github.com/dujiao-next/internal/version"
|
||||
@@ -95,9 +94,6 @@ func main() {
|
||||
if err := databasemigrations.AutoMigrate(); err != nil {
|
||||
stdLog.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
if err := resellerstore.Migrate(gormdb.DB); err != nil {
|
||||
stdLog.Fatalf("分销模块数据库迁移失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化默认管理员账号
|
||||
if cfg.Server.Mode == "release" && defaultAdminPass == "" {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -19,17 +25,19 @@ import (
|
||||
// 目的:避免新增 admin 接口时忘记同步 RBAC 预置角色,导致非超管角色无法通过
|
||||
// 角色分配获得该权限(catalog UI 上能看到,但任何角色都拿不到)。
|
||||
//
|
||||
// 实现:静态扫描 routes_admin*.go 提取 authorized.METHOD("/path", ...) 与
|
||||
// paymentProtected.METHOD("/path", ...) 调用,与
|
||||
// builtin role seeds 用 keyMatch2 比对(与运行时 Casbin 模型一致)。
|
||||
// 实现:静态扫描 routes_admin*.go、internal/modules/**/routes.go
|
||||
// 以及 internal/platform/http/**/routes.go,
|
||||
// 提取 admin/authorized/paymentProtected.METHOD("/path", ...) 调用,
|
||||
// 与 builtin role seeds 用 keyMatch2 比对(与运行时 Casbin 模型一致)。
|
||||
func TestAllAdminRoutesCoveredByBuiltinRoles(t *testing.T) {
|
||||
routes, err := extractAdminRoutesFromSource()
|
||||
if err != nil {
|
||||
t.Fatalf("extract admin routes: %v", err)
|
||||
}
|
||||
if len(routes) == 0 {
|
||||
t.Fatalf("no admin routes extracted; regex or source layout changed?")
|
||||
t.Fatalf("no admin routes extracted; parser or source layout changed?")
|
||||
}
|
||||
t.Logf("validating RBAC coverage for %d admin routes", len(routes))
|
||||
|
||||
seeds := authz.BuiltinRoleSeeds()
|
||||
if len(seeds) == 0 {
|
||||
@@ -82,93 +90,163 @@ type adminRoute struct {
|
||||
object string // 例如 "/admin/users/:id"
|
||||
}
|
||||
|
||||
// extractAdminRoutesFromSource 从 admin 路由文件和已抽取的模块路由文件中读取调用。
|
||||
var adminRouteMethods = map[string]struct{}{
|
||||
"GET": {},
|
||||
"POST": {},
|
||||
"PUT": {},
|
||||
"PATCH": {},
|
||||
"DELETE": {},
|
||||
}
|
||||
|
||||
var adminRouteReceivers = map[string]struct{}{
|
||||
"admin": {},
|
||||
"authorized": {},
|
||||
"paymentProtected": {},
|
||||
}
|
||||
|
||||
var publicAdminRoutes = map[string]struct{}{
|
||||
"POST /admin/login": {},
|
||||
"POST /admin/login/verify-2fa": {},
|
||||
}
|
||||
|
||||
// extractAdminRoutesFromSource 从应用 admin 路由、模块路由和平台 HTTP 路由文件中读取调用。
|
||||
// 方法范围:GET / POST / PUT / PATCH / DELETE。HEAD/OPTIONS 不参与 RBAC。
|
||||
func extractAdminRoutesFromSource() ([]adminRoute, error) {
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
routerDirectory := filepath.Dir(thisFile)
|
||||
type routeSource struct {
|
||||
path string
|
||||
expression *regexp.Regexp
|
||||
}
|
||||
|
||||
adminSources, err := filepath.Glob(filepath.Join(routerDirectory, "routes_admin*.go"))
|
||||
sources, err := discoverAdminRouteSources(routerDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(adminSources) == 0 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
sources := make([]routeSource, 0, len(adminSources)+8)
|
||||
for _, path := range adminSources {
|
||||
if strings.HasSuffix(path, "_test.go") {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, routeSource{
|
||||
path: path,
|
||||
expression: regexp.MustCompile(`(?:authorized|paymentProtected)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
|
||||
})
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
sources = append(sources,
|
||||
routeSource{
|
||||
path: filepath.Join(routerDirectory, "..", "..", "modules", "content", "transport", "http", "routes.go"),
|
||||
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
|
||||
},
|
||||
routeSource{
|
||||
path: filepath.Join(routerDirectory, "..", "..", "modules", "dashboard", "transport", "http", "routes.go"),
|
||||
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
|
||||
},
|
||||
routeSource{
|
||||
path: filepath.Join(routerDirectory, "..", "..", "modules", "memberlevel", "transport", "http", "routes.go"),
|
||||
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
|
||||
},
|
||||
routeSource{
|
||||
path: filepath.Join(routerDirectory, "..", "..", "modules", "apicredential", "transport", "http", "routes.go"),
|
||||
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
|
||||
},
|
||||
routeSource{
|
||||
path: filepath.Join(routerDirectory, "..", "..", "modules", "auditlog", "transport", "http", "routes.go"),
|
||||
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
|
||||
},
|
||||
routeSource{
|
||||
path: filepath.Join(routerDirectory, "..", "..", "modules", "coupon", "transport", "http", "routes.go"),
|
||||
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
|
||||
},
|
||||
routeSource{
|
||||
path: filepath.Join(routerDirectory, "..", "..", "modules", "promotion", "transport", "http", "routes.go"),
|
||||
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
|
||||
},
|
||||
routeSource{
|
||||
path: filepath.Join(routerDirectory, "..", "..", "modules", "giftcard", "transport", "http", "routes.go"),
|
||||
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
|
||||
},
|
||||
routeSource{
|
||||
path: filepath.Join(routerDirectory, "..", "..", "modules", "notification", "transport", "http", "routes.go"),
|
||||
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
|
||||
},
|
||||
)
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var out []adminRoute
|
||||
for _, source := range sources {
|
||||
raw, err := os.ReadFile(source.path)
|
||||
routes, err := extractAdminRoutesFromFile(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, match := range source.expression.FindAllStringSubmatch(string(raw), -1) {
|
||||
method := match[1]
|
||||
object := authz.NormalizeObject("/admin" + match[2])
|
||||
key := method + " " + object
|
||||
for _, route := range routes {
|
||||
key := route.method + " " + route.object
|
||||
if _, public := publicAdminRoutes[key]; public {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, adminRoute{method: method, object: object})
|
||||
out = append(out, route)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].object == out[j].object {
|
||||
return out[i].method < out[j].method
|
||||
}
|
||||
return out[i].object < out[j].object
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func discoverAdminRouteSources(routerDirectory string) ([]string, error) {
|
||||
adminSources, err := filepath.Glob(filepath.Join(routerDirectory, "routes_admin*.go"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sources := make([]string, 0, len(adminSources)+32)
|
||||
for _, path := range adminSources {
|
||||
if !strings.HasSuffix(path, "_test.go") {
|
||||
sources = append(sources, path)
|
||||
}
|
||||
}
|
||||
|
||||
for _, directory := range []string{
|
||||
filepath.Join(routerDirectory, "..", "..", "modules"),
|
||||
filepath.Join(routerDirectory, "..", "..", "platform", "http"),
|
||||
} {
|
||||
if err := filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if !entry.IsDir() && entry.Name() == "routes.go" {
|
||||
sources = append(sources, path)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(sources)
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
func TestExtractAdminRoutesIncludesPlatformHTTPRoutes(t *testing.T) {
|
||||
routes, err := extractAdminRoutesFromSource()
|
||||
if err != nil {
|
||||
t.Fatalf("extract admin routes: %v", err)
|
||||
}
|
||||
for _, route := range routes {
|
||||
if route.method == "GET" && route.object == "/admin/system/version/check" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("platform HTTP route GET /admin/system/version/check was not discovered")
|
||||
}
|
||||
|
||||
func extractAdminRoutesFromFile(path string) ([]adminRoute, error) {
|
||||
fileSet := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fileSet, path, nil, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var routes []adminRoute
|
||||
var routeErr error
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
if routeErr != nil {
|
||||
return false
|
||||
}
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) == 0 {
|
||||
return true
|
||||
}
|
||||
selector, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
receiver, ok := selector.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := adminRouteReceivers[receiver.Name]; !ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := adminRouteMethods[selector.Sel.Name]; !ok {
|
||||
return true
|
||||
}
|
||||
pathLiteral, ok := call.Args[0].(*ast.BasicLit)
|
||||
if !ok || pathLiteral.Kind != token.STRING {
|
||||
routeErr = fmt.Errorf("%s:%d: admin route path must be a string literal", path, fileSet.Position(call.Args[0].Pos()).Line)
|
||||
return false
|
||||
}
|
||||
routePath, err := strconv.Unquote(pathLiteral.Value)
|
||||
if err != nil {
|
||||
routeErr = fmt.Errorf("%s:%d: decode admin route path: %w", path, fileSet.Position(pathLiteral.Pos()).Line, err)
|
||||
return false
|
||||
}
|
||||
routes = append(routes, adminRoute{
|
||||
method: selector.Sel.Name,
|
||||
object: authz.NormalizeObject("/admin" + routePath),
|
||||
})
|
||||
return true
|
||||
})
|
||||
if routeErr != nil {
|
||||
return nil, routeErr
|
||||
}
|
||||
return routes, nil
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
admincontract "github.com/dujiao-next/internal/modules/identity/admin/contract"
|
||||
admindomain "github.com/dujiao-next/internal/modules/identity/admin/domain"
|
||||
adminauthapp "github.com/dujiao-next/internal/modules/identity/adminauth/application"
|
||||
"github.com/dujiao-next/internal/shared/passwordpolicy"
|
||||
adminauthztransport "github.com/dujiao-next/internal/modules/identity/adminauthorization/transport/http"
|
||||
"github.com/dujiao-next/internal/shared/passwordpolicy"
|
||||
)
|
||||
|
||||
type adminAuthzRolePolicyAdapter struct {
|
||||
|
||||
@@ -18,11 +18,11 @@ import (
|
||||
couponcontract "github.com/dujiao-next/internal/modules/coupon/contract"
|
||||
externalidentitycontract "github.com/dujiao-next/internal/modules/identity/externalidentity/contract"
|
||||
externalidentitydomain "github.com/dujiao-next/internal/modules/identity/externalidentity/domain"
|
||||
adminusertransport "github.com/dujiao-next/internal/modules/identity/user/transport/http/admin"
|
||||
userauthapp "github.com/dujiao-next/internal/modules/identity/userauth/application"
|
||||
walletapp "github.com/dujiao-next/internal/modules/wallet/application"
|
||||
walletdomain "github.com/dujiao-next/internal/modules/wallet/domain"
|
||||
"github.com/dujiao-next/internal/shared/money"
|
||||
adminusertransport "github.com/dujiao-next/internal/modules/identity/user/transport/http/admin"
|
||||
)
|
||||
|
||||
type adminUserDirectoryAdapter struct {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
|
||||
orderdomain "github.com/dujiao-next/internal/modules/order/domain"
|
||||
paymentdomain "github.com/dujiao-next/internal/modules/payment/domain"
|
||||
procurementdomain "github.com/dujiao-next/internal/modules/procurement/domain"
|
||||
settingsstore "github.com/dujiao-next/internal/modules/settings/infrastructure/gormstore"
|
||||
"github.com/dujiao-next/internal/platform/database/gormdb"
|
||||
"github.com/dujiao-next/internal/shared/jsonmap"
|
||||
@@ -27,8 +28,120 @@ const (
|
||||
paymentChannelBepusdtConfigMigrationSettingKey = "migration/payment_channel_bepusdt_config_v2"
|
||||
orderItemOriginalPriceMigrationKey = "migration/order_item_original_price_v1"
|
||||
manualStockUnlimitedValue = -1
|
||||
cartProductForeignKeyConstraint = "fk_cart_items_product"
|
||||
cartSKUForeignKeyConstraint = "fk_cart_items_sku"
|
||||
procurementOrderForeignKeyConstraint = "fk_procurement_orders_local_order"
|
||||
supersededProcurementOrderForeignKeyConstraint = "fk_procurement_orders_local_order_reference"
|
||||
)
|
||||
|
||||
type cartItemConstraintSchema struct {
|
||||
cartdomain.Item
|
||||
Product *cartProductReference `gorm:"foreignKey:ProductID;references:ID;constraint:fk_cart_items_product"`
|
||||
SKU *cartProductSKUReference `gorm:"foreignKey:SKUID;references:ID;constraint:fk_cart_items_sku"`
|
||||
}
|
||||
|
||||
func (cartItemConstraintSchema) TableName() string { return "cart_items" }
|
||||
|
||||
type cartProductReference struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
}
|
||||
|
||||
func (cartProductReference) TableName() string { return "products" }
|
||||
|
||||
type cartProductSKUReference struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
}
|
||||
|
||||
func (cartProductSKUReference) TableName() string { return "product_skus" }
|
||||
|
||||
func ensureCartForeignKeyConstraints() error {
|
||||
if gormdb.DB == nil {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
migrator := gormdb.DB.Migrator()
|
||||
missing := make([]struct {
|
||||
name string
|
||||
relation string
|
||||
}, 0, 2)
|
||||
for _, constraint := range []struct {
|
||||
name string
|
||||
relation string
|
||||
}{
|
||||
{name: cartProductForeignKeyConstraint, relation: "Product"},
|
||||
{name: cartSKUForeignKeyConstraint, relation: "SKU"},
|
||||
} {
|
||||
if migrator.HasConstraint(&cartItemConstraintSchema{}, constraint.name) {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, constraint)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
if err := validateCartForeignKeyReferences(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, constraint := range missing {
|
||||
if err := migrator.CreateConstraint(&cartItemConstraintSchema{}, constraint.relation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite 通过重建表来增加外键,重建过程不会保留原表索引。
|
||||
// 即使外键已经存在也要执行,以修复曾被旧迁移版本移除的索引。
|
||||
return gormdb.DB.AutoMigrate(&cartdomain.Item{})
|
||||
}
|
||||
|
||||
func validateCartForeignKeyReferences() error {
|
||||
for _, reference := range []struct {
|
||||
constraint string
|
||||
table string
|
||||
column string
|
||||
}{
|
||||
{constraint: cartProductForeignKeyConstraint, table: "products", column: "product_id"},
|
||||
{constraint: cartSKUForeignKeyConstraint, table: "product_skus", column: "sku_id"},
|
||||
} {
|
||||
var orphanCount int64
|
||||
query := fmt.Sprintf(
|
||||
"SELECT COUNT(*) FROM cart_items AS cart LEFT JOIN %s AS target ON target.id = cart.%s WHERE target.id IS NULL",
|
||||
reference.table,
|
||||
reference.column,
|
||||
)
|
||||
if err := gormdb.DB.Raw(query).Scan(&orphanCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if orphanCount > 0 {
|
||||
return fmt.Errorf(
|
||||
"cannot create %s: %d cart_items rows reference missing %s",
|
||||
reference.constraint,
|
||||
orphanCount,
|
||||
reference.table,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureProcurementOrderForeignKeyConstraint() error {
|
||||
if gormdb.DB == nil {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
migrator := gormdb.DB.Migrator()
|
||||
if migrator.HasConstraint(&procurementdomain.Order{}, supersededProcurementOrderForeignKeyConstraint) {
|
||||
if err := migrator.DropConstraint(&procurementdomain.Order{}, supersededProcurementOrderForeignKeyConstraint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !migrator.HasConstraint(&procurementdomain.Order{}, procurementOrderForeignKeyConstraint) {
|
||||
if err := migrator.CreateConstraint(&procurementdomain.Order{}, "LocalOrderReference"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite 的 DropConstraint/CreateConstraint 都可能重建表并丢失索引。
|
||||
// 无条件同步模型索引,同时让已经运行过旧迁移的数据库可以自愈。
|
||||
return gormdb.DB.AutoMigrate(&procurementdomain.Order{})
|
||||
}
|
||||
|
||||
// ensureManualStockRemainingMigration 将历史“总量库存”迁移为“剩余库存”语义,仅执行一次。
|
||||
func ensureManualStockRemainingMigration() error {
|
||||
if gormdb.DB == nil {
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
procurementdomain "github.com/dujiao-next/internal/modules/procurement/domain"
|
||||
promotiondomain "github.com/dujiao-next/internal/modules/promotion/domain"
|
||||
reconciliationdomain "github.com/dujiao-next/internal/modules/reconciliation/domain"
|
||||
resellerstore "github.com/dujiao-next/internal/modules/reseller/infrastructure/gormstore"
|
||||
settingsstore "github.com/dujiao-next/internal/modules/settings/infrastructure/gormstore"
|
||||
siteconnectiondomain "github.com/dujiao-next/internal/modules/siteconnection/domain"
|
||||
broadcastdomain "github.com/dujiao-next/internal/modules/telegram/broadcast/domain"
|
||||
@@ -91,6 +92,9 @@ func AutoMigrate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := resellerstore.Migrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateCartSKUUniqueIndex(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -112,6 +116,12 @@ func AutoMigrate() error {
|
||||
if err := ensureOrderItemOriginalPriceMigration(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureCartForeignKeyConstraints(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureProcurementOrderForeignKeyConstraint(); err != nil {
|
||||
return err
|
||||
}
|
||||
if db.Migrator().HasColumn(&productdomain.Product{}, "price_currency") {
|
||||
if err := db.Migrator().DropColumn(&productdomain.Product{}, "price_currency"); err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,6 +2,7 @@ package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
|
||||
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
|
||||
orderdomain "github.com/dujiao-next/internal/modules/order/domain"
|
||||
procurementdomain "github.com/dujiao-next/internal/modules/procurement/domain"
|
||||
resellerdomain "github.com/dujiao-next/internal/modules/reseller/domain"
|
||||
settingsstore "github.com/dujiao-next/internal/modules/settings/infrastructure/gormstore"
|
||||
"github.com/dujiao-next/internal/platform/database/gormdb"
|
||||
"github.com/dujiao-next/internal/shared/jsonmap"
|
||||
@@ -21,6 +24,10 @@ import (
|
||||
|
||||
func setupSKUMigrationTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
previousDB := gormdb.DB
|
||||
t.Cleanup(func() {
|
||||
gormdb.DB = previousDB
|
||||
})
|
||||
dsn := fmt.Sprintf("file:sku_migration_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
@@ -32,6 +39,59 @@ func setupSKUMigrationTestDB(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func setupRegistryMigrationTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
previousDB := gormdb.DB
|
||||
t.Cleanup(func() {
|
||||
gormdb.DB = previousDB
|
||||
})
|
||||
dsn := fmt.Sprintf("file:registry_migration_test_%d?mode=memory&cache=shared&_pragma=foreign_keys(1)", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite failed: %v", err)
|
||||
}
|
||||
gormdb.DB = db
|
||||
return db
|
||||
}
|
||||
|
||||
func modelIndexNames(t *testing.T, db *gorm.DB, model interface{}) []string {
|
||||
t.Helper()
|
||||
statement := &gorm.Statement{DB: db}
|
||||
if err := statement.Parse(model); err != nil {
|
||||
t.Fatalf("parse model indexes: %v", err)
|
||||
}
|
||||
indexes := statement.Schema.ParseIndexes()
|
||||
names := make([]string, 0, len(indexes))
|
||||
for _, index := range indexes {
|
||||
names = append(names, index.Name)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
t.Fatalf("model %T does not declare any indexes", model)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func assertModelIndexesExist(t *testing.T, db *gorm.DB, model interface{}) {
|
||||
t.Helper()
|
||||
for _, name := range modelIndexNames(t, db, model) {
|
||||
if !db.Migrator().HasIndex(model, name) {
|
||||
t.Errorf("model %T index %s does not exist", model, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dropModelIndexes(t *testing.T, db *gorm.DB, model interface{}) {
|
||||
t.Helper()
|
||||
for _, name := range modelIndexNames(t, db, model) {
|
||||
if !db.Migrator().HasIndex(model, name) {
|
||||
continue
|
||||
}
|
||||
if err := db.Migrator().DropIndex(model, name); err != nil {
|
||||
t.Fatalf("drop model %T index %s: %v", model, name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureProductSKUMigrationBackfillLegacyData(t *testing.T) {
|
||||
db := setupSKUMigrationTestDB(t)
|
||||
|
||||
@@ -201,3 +261,198 @@ func TestMigrateCartSKUUniqueIndex(t *testing.T) {
|
||||
t.Fatalf("new unique index idx_cart_user_product_sku should exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMigrateOwnsResellerSchemaAndCrossModuleConstraints(t *testing.T) {
|
||||
db := setupRegistryMigrationTestDB(t)
|
||||
if err := AutoMigrate(); err != nil {
|
||||
t.Fatalf("auto migrate failed: %v", err)
|
||||
}
|
||||
if err := AutoMigrate(); err != nil {
|
||||
t.Fatalf("idempotent auto migrate failed: %v", err)
|
||||
}
|
||||
for _, table := range []string{
|
||||
"reseller_profiles",
|
||||
"reseller_domains",
|
||||
"reseller_site_configs",
|
||||
"reseller_product_settings",
|
||||
"reseller_order_snapshots",
|
||||
"reseller_ledger_entries",
|
||||
"reseller_withdraw_requests",
|
||||
"reseller_balance_accounts",
|
||||
"reseller_related_accounts",
|
||||
} {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
t.Errorf("central AutoMigrate did not create reseller table %s", table)
|
||||
}
|
||||
}
|
||||
for _, index := range []struct {
|
||||
model interface{}
|
||||
name string
|
||||
}{
|
||||
{model: &resellerdomain.Domain{}, name: "idx_reseller_domains_active_domain"},
|
||||
{model: &resellerdomain.SiteConfig{}, name: "idx_reseller_site_configs_active_reseller"},
|
||||
{model: &resellerdomain.ProductSetting{}, name: "idx_reseller_product_settings_active_scope"},
|
||||
{model: &resellerdomain.BalanceAccount{}, name: "idx_reseller_balance_accounts_active_currency"},
|
||||
{model: &resellerdomain.RelatedAccount{}, name: "idx_reseller_related_accounts_active_user"},
|
||||
} {
|
||||
if !db.Migrator().HasIndex(index.model, index.name) {
|
||||
t.Errorf("central AutoMigrate did not create reseller index %s", index.name)
|
||||
}
|
||||
}
|
||||
for _, constraint := range []string{
|
||||
cartProductForeignKeyConstraint,
|
||||
cartSKUForeignKeyConstraint,
|
||||
} {
|
||||
if !db.Migrator().HasConstraint(&cartItemConstraintSchema{}, constraint) {
|
||||
t.Errorf("central AutoMigrate did not create cart constraint %s", constraint)
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasConstraint(&procurementdomain.Order{}, procurementOrderForeignKeyConstraint) {
|
||||
t.Errorf("central AutoMigrate did not create procurement constraint %s", procurementOrderForeignKeyConstraint)
|
||||
}
|
||||
if db.Migrator().HasConstraint(&procurementdomain.Order{}, supersededProcurementOrderForeignKeyConstraint) {
|
||||
t.Errorf("central AutoMigrate retained superseded procurement constraint %s", supersededProcurementOrderForeignKeyConstraint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCartForeignKeyConstraintsPreservesRowsAndEnforcesReferences(t *testing.T) {
|
||||
db := setupRegistryMigrationTestDB(t)
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE products (id integer PRIMARY KEY)`,
|
||||
`CREATE TABLE product_skus (id integer PRIMARY KEY)`,
|
||||
`INSERT INTO products (id) VALUES (1)`,
|
||||
`INSERT INTO product_skus (id) VALUES (2)`,
|
||||
} {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatalf("prepare cart schema: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.AutoMigrate(&cartdomain.Item{}); err != nil {
|
||||
t.Fatalf("prepare indexed cart schema: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO cart_items (id, user_id, product_id, sku_id, quantity) VALUES (3, 4, 1, 2, 1)`).Error; err != nil {
|
||||
t.Fatalf("prepare cart row: %v", err)
|
||||
}
|
||||
assertModelIndexesExist(t, db, &cartdomain.Item{})
|
||||
|
||||
if err := ensureCartForeignKeyConstraints(); err != nil {
|
||||
t.Fatalf("ensure cart constraints: %v", err)
|
||||
}
|
||||
assertModelIndexesExist(t, db, &cartdomain.Item{})
|
||||
var rowCount int64
|
||||
if err := db.Table("cart_items").Where("id = ?", 3).Count(&rowCount).Error; err != nil {
|
||||
t.Fatalf("count preserved cart rows: %v", err)
|
||||
}
|
||||
if rowCount != 1 {
|
||||
t.Fatalf("cart constraint migration lost existing row: count=%d", rowCount)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO cart_items (id, user_id, product_id, sku_id, quantity) VALUES (5, 6, 999, 2, 1)`).Error; err == nil {
|
||||
t.Fatal("cart product foreign key did not reject missing product")
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO cart_items (id, user_id, product_id, sku_id, quantity) VALUES (7, 8, 1, 999, 1)`).Error; err == nil {
|
||||
t.Fatal("cart SKU foreign key did not reject missing SKU")
|
||||
}
|
||||
|
||||
dropModelIndexes(t, db, &cartdomain.Item{})
|
||||
if err := ensureCartForeignKeyConstraints(); err != nil {
|
||||
t.Fatalf("repair cart indexes with existing constraints: %v", err)
|
||||
}
|
||||
assertModelIndexesExist(t, db, &cartdomain.Item{})
|
||||
for _, constraint := range []string{cartProductForeignKeyConstraint, cartSKUForeignKeyConstraint} {
|
||||
if !db.Migrator().HasConstraint(&cartItemConstraintSchema{}, constraint) {
|
||||
t.Errorf("cart index repair removed constraint %s", constraint)
|
||||
}
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO cart_items (id, user_id, product_id, sku_id, quantity) VALUES (9, 4, 1, 2, 1)`).Error; err == nil {
|
||||
t.Fatal("repaired cart unique index did not reject duplicate user/product/SKU")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCartForeignKeyConstraintsRejectsOrphansBeforeSchemaChanges(t *testing.T) {
|
||||
db := setupRegistryMigrationTestDB(t)
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE products (id integer PRIMARY KEY)`,
|
||||
`CREATE TABLE product_skus (id integer PRIMARY KEY)`,
|
||||
`CREATE TABLE cart_items (id integer PRIMARY KEY, user_id integer NOT NULL, product_id integer NOT NULL, sku_id integer NOT NULL, quantity integer NOT NULL)`,
|
||||
`INSERT INTO product_skus (id) VALUES (2)`,
|
||||
`INSERT INTO cart_items (id, user_id, product_id, sku_id, quantity) VALUES (3, 4, 999, 2, 1)`,
|
||||
} {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatalf("prepare orphaned cart schema: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := ensureCartForeignKeyConstraints()
|
||||
if err == nil || !strings.Contains(err.Error(), cartProductForeignKeyConstraint) {
|
||||
t.Fatalf("expected descriptive product reference error, got %v", err)
|
||||
}
|
||||
for _, constraint := range []string{cartProductForeignKeyConstraint, cartSKUForeignKeyConstraint} {
|
||||
if db.Migrator().HasConstraint(&cartItemConstraintSchema{}, constraint) {
|
||||
t.Fatalf("orphan preflight left partial constraint %s", constraint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type supersededProcurementOrderSchema struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
LocalOrderID uint
|
||||
LocalOrderReference *supersededProcurementOrderReference `gorm:"foreignKey:LocalOrderID;references:ID;constraint:fk_procurement_orders_local_order_reference"`
|
||||
}
|
||||
|
||||
func (supersededProcurementOrderSchema) TableName() string { return "procurement_orders" }
|
||||
|
||||
type supersededProcurementOrderReference struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
}
|
||||
|
||||
func (supersededProcurementOrderReference) TableName() string { return "orders" }
|
||||
|
||||
type procurementOrderIndexSchema struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
ConnectionID uint `gorm:"index"`
|
||||
LocalOrderID uint `gorm:"index"`
|
||||
LocalOrderNo string `gorm:"index"`
|
||||
UpstreamOrderNo string `gorm:"index"`
|
||||
Status string `gorm:"index"`
|
||||
NextRetryAt *time.Time `gorm:"index"`
|
||||
TraceID string `gorm:"index"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time `gorm:"index"`
|
||||
DeletedAt *time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
func (procurementOrderIndexSchema) TableName() string { return "procurement_orders" }
|
||||
|
||||
func TestAutoMigrateReplacesSupersededProcurementConstraintName(t *testing.T) {
|
||||
db := setupRegistryMigrationTestDB(t)
|
||||
if err := db.AutoMigrate(&supersededProcurementOrderSchema{}); err != nil {
|
||||
t.Fatalf("create superseded procurement schema failed: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&procurementOrderIndexSchema{}); err != nil {
|
||||
t.Fatalf("create indexed superseded procurement schema failed: %v", err)
|
||||
}
|
||||
assertModelIndexesExist(t, db, &procurementdomain.Order{})
|
||||
if !db.Migrator().HasConstraint(&supersededProcurementOrderSchema{}, supersededProcurementOrderForeignKeyConstraint) {
|
||||
t.Fatalf("test setup did not create superseded procurement constraint")
|
||||
}
|
||||
|
||||
if err := AutoMigrate(); err != nil {
|
||||
t.Fatalf("auto migrate superseded schema failed: %v", err)
|
||||
}
|
||||
if db.Migrator().HasConstraint(&procurementdomain.Order{}, supersededProcurementOrderForeignKeyConstraint) {
|
||||
t.Errorf("superseded procurement constraint %s still exists", supersededProcurementOrderForeignKeyConstraint)
|
||||
}
|
||||
if !db.Migrator().HasConstraint(&procurementdomain.Order{}, procurementOrderForeignKeyConstraint) {
|
||||
t.Errorf("canonical procurement constraint %s does not exist", procurementOrderForeignKeyConstraint)
|
||||
}
|
||||
assertModelIndexesExist(t, db, &procurementdomain.Order{})
|
||||
|
||||
dropModelIndexes(t, db, &procurementdomain.Order{})
|
||||
if err := ensureProcurementOrderForeignKeyConstraint(); err != nil {
|
||||
t.Fatalf("repair procurement indexes with canonical constraint: %v", err)
|
||||
}
|
||||
assertModelIndexesExist(t, db, &procurementdomain.Order{})
|
||||
if !db.Migrator().HasConstraint(&procurementdomain.Order{}, procurementOrderForeignKeyConstraint) {
|
||||
t.Errorf("procurement index repair removed canonical constraint %s", procurementOrderForeignKeyConstraint)
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,8 @@ import (
|
||||
userauthapp "github.com/dujiao-next/internal/modules/identity/userauth/application"
|
||||
"github.com/dujiao-next/internal/modules/identity/userauth/challenge"
|
||||
usertotpapp "github.com/dujiao-next/internal/modules/identity/userauth/totp/application"
|
||||
notificationcontract "github.com/dujiao-next/internal/modules/notification/contract"
|
||||
userauthtransport "github.com/dujiao-next/internal/modules/identity/userauth/transport/http"
|
||||
notificationcontract "github.com/dujiao-next/internal/modules/notification/contract"
|
||||
)
|
||||
|
||||
// userProfileTransportAdapter 将用户认证服务适配为用户资料 transport 端口。
|
||||
|
||||
@@ -250,7 +250,7 @@ func (r *Store) ListCommissions(filter affiliatecontract.CommissionListFilter) (
|
||||
Where("affiliate_commissions.deleted_at IS NULL").
|
||||
Preload("AffiliateProfile", "deleted_at IS NULL").
|
||||
Preload("AffiliateProfile.User", "deleted_at IS NULL").
|
||||
Preload("Order")
|
||||
Preload("Order", "deleted_at IS NULL")
|
||||
if filter.AffiliateProfileID != 0 {
|
||||
query = query.Where("affiliate_commissions.affiliate_profile_id = ?", filter.AffiliateProfileID)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
affiliatecontract "github.com/dujiao-next/internal/modules/affiliate/contract"
|
||||
affiliatedomain "github.com/dujiao-next/internal/modules/affiliate/domain"
|
||||
userdomain "github.com/dujiao-next/internal/modules/identity/user/domain"
|
||||
orderdomain "github.com/dujiao-next/internal/modules/order/domain"
|
||||
"github.com/dujiao-next/internal/shared/money"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
@@ -26,6 +27,7 @@ func openTestStore(t *testing.T) (*Store, *gorm.DB) {
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&userdomain.User{},
|
||||
&orderdomain.Order{},
|
||||
&affiliatedomain.Profile{},
|
||||
&affiliatedomain.Click{},
|
||||
&affiliatedomain.Commission{},
|
||||
@@ -109,6 +111,52 @@ func TestStoreExcludesSoftDeletedAffiliateRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreDoesNotPreloadSoftDeletedCommissionOrder(t *testing.T) {
|
||||
store, db := openTestStore(t)
|
||||
user := &userdomain.User{Email: "affiliate-deleted-order@example.com", PasswordHash: "x", Status: constants.UserStatusActive}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
profile := createTestProfile(t, store, user.ID, "AFFDELETEDORDER")
|
||||
order := &orderdomain.Order{
|
||||
OrderNo: "AFF-DELETED-ORDER",
|
||||
Status: constants.OrderStatusPaid,
|
||||
Currency: "USD",
|
||||
}
|
||||
if err := db.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
commission := &affiliatedomain.Commission{
|
||||
AffiliateProfileID: profile.ID,
|
||||
OrderID: order.ID,
|
||||
CommissionType: constants.AffiliateCommissionTypeOrder,
|
||||
CommissionAmount: money.FromDecimal(decimal.NewFromInt(12)),
|
||||
Status: constants.AffiliateCommissionStatusAvailable,
|
||||
}
|
||||
if err := store.CreateCommission(commission); err != nil {
|
||||
t.Fatalf("create commission: %v", err)
|
||||
}
|
||||
deletedAt := time.Now()
|
||||
if err := db.Model(order).Update("deleted_at", &deletedAt).Error; err != nil {
|
||||
t.Fatalf("soft delete order: %v", err)
|
||||
}
|
||||
|
||||
rows, total, err := store.ListCommissions(affiliatecontract.CommissionListFilter{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
OrderID: order.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list commissions: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 {
|
||||
t.Fatalf("expected commission to remain visible, total=%d rows=%d", total, len(rows))
|
||||
}
|
||||
if rows[0].Order.ID != 0 {
|
||||
t.Fatalf("soft-deleted order leaked through preload: %+v", rows[0].Order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreWithinTransactionRollsBack(t *testing.T) {
|
||||
store, db := openTestStore(t)
|
||||
wantErr := errors.New("rollback")
|
||||
|
||||
@@ -21,9 +21,9 @@ import (
|
||||
"github.com/dujiao-next/internal/constants"
|
||||
categorygormstore "github.com/dujiao-next/internal/modules/catalog/category/infrastructure/gormstore"
|
||||
productapplication "github.com/dujiao-next/internal/modules/catalog/product/application"
|
||||
channeltransport "github.com/dujiao-next/internal/modules/channelapi/transport/http"
|
||||
"github.com/dujiao-next/internal/shared/jsonmap"
|
||||
"github.com/dujiao-next/internal/shared/money"
|
||||
channeltransport "github.com/dujiao-next/internal/modules/channelapi/transport/http"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
@@ -36,7 +36,7 @@ type Order struct {
|
||||
|
||||
Connection *siteconnectiondomain.Connection `gorm:"foreignKey:ConnectionID" json:"connection,omitempty"`
|
||||
LocalOrder *LocalOrder `gorm:"-" json:"local_order,omitempty"`
|
||||
LocalOrderReference *LocalOrderReference `gorm:"foreignKey:LocalOrderID;references:ID" json:"-"`
|
||||
LocalOrderReference *LocalOrderReference `gorm:"foreignKey:LocalOrderID;references:ID;constraint:fk_procurement_orders_local_order,OnUpdate:NO ACTION,OnDelete:NO ACTION" json:"-"`
|
||||
ParentOrderNo string `gorm:"-" json:"parent_order_no,omitempty"`
|
||||
UpstreamRefundRecords []jsonmap.JSON `gorm:"-" json:"upstream_refund_records,omitempty"`
|
||||
UpstreamRefundedAmount string `gorm:"-" json:"upstream_refunded_amount,omitempty"`
|
||||
|
||||
@@ -239,6 +239,44 @@ func TestResellerAccountingRepositoryListAdminLedgerEntriesFiltersByKeywordAndOr
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerAccountingRepositoryDoesNotPreloadSoftDeletedOrder(t *testing.T) {
|
||||
db := openResellerAccountingRepoTestDB(t)
|
||||
repo := New(db)
|
||||
profile := seedResellerAccountingProfileWithEmail(t, db, "deleted-order-ledger@example.com")
|
||||
order := seedResellerAccountingOrder(t, db, "DELETED-LEDGER-ORDER")
|
||||
entry := resellerdomain.LedgerEntry{
|
||||
ResellerID: profile.ID,
|
||||
OrderID: &order.ID,
|
||||
Type: resellerdomain.LedgerTypeOrderProfit,
|
||||
Amount: money.FromDecimal(decimal.NewFromInt(12)),
|
||||
Currency: "USD",
|
||||
IdempotencyKey: "deleted-order-ledger",
|
||||
Status: resellerdomain.LedgerStatusAvailable,
|
||||
}
|
||||
if err := db.Create(&entry).Error; err != nil {
|
||||
t.Fatalf("create ledger failed: %v", err)
|
||||
}
|
||||
deletedAt := time.Now()
|
||||
if err := db.Model(&order).Update("deleted_at", &deletedAt).Error; err != nil {
|
||||
t.Fatalf("soft delete order failed: %v", err)
|
||||
}
|
||||
|
||||
rows, total, err := repo.ListAdminResellerLedgerEntries(resellercontract.AdminLedgerListFilter{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
OrderID: order.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAdminResellerLedgerEntries failed: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 {
|
||||
t.Fatalf("expected ledger to remain visible, total=%d rows=%d", total, len(rows))
|
||||
}
|
||||
if rows[0].Order != nil {
|
||||
t.Fatalf("soft-deleted order leaked through preload: %+v", rows[0].Order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerAccountingRepositoryListAdminBalanceAccountsFiltersAndPreloadsProfile(t *testing.T) {
|
||||
db := openResellerAccountingRepoTestDB(t)
|
||||
repo := New(db)
|
||||
|
||||
@@ -17,7 +17,7 @@ func (r *Store) ListAdminResellerLedgerEntries(filter resellercontract.AdminLedg
|
||||
query := r.db.Model(&resellerdomain.LedgerEntry{}).
|
||||
Preload("Profile", "deleted_at IS NULL").
|
||||
Preload("Profile.User", "deleted_at IS NULL").
|
||||
Preload("Order").
|
||||
Preload("Order", "deleted_at IS NULL").
|
||||
Where("reseller_ledger_entries.deleted_at IS NULL")
|
||||
|
||||
query = r.applyAdminResellerProfileFilters(query, "reseller_ledger_entries", filter.ResellerID, filter.UserID, filter.Keyword, "")
|
||||
|
||||
@@ -126,7 +126,7 @@ func (r *Store) FindActiveVerifiedDomain(host string) (*resellerdomain.Domain, e
|
||||
return nil, nil
|
||||
}
|
||||
var row resellerdomain.Domain
|
||||
err := r.db.Preload("Profile").
|
||||
err := r.db.Preload("Profile", "deleted_at IS NULL").
|
||||
Joins("JOIN reseller_profiles ON reseller_profiles.id = reseller_domains.reseller_id AND reseller_profiles.deleted_at IS NULL").
|
||||
Where("reseller_domains.domain = ? AND reseller_domains.status = ? AND reseller_domains.verification_status = ? AND reseller_domains.deleted_at IS NULL", domain, resellerdomain.DomainStatusActive, resellerdomain.DomainVerificationVerified).
|
||||
Where("reseller_profiles.status = ?", resellerdomain.ProfileStatusActive).
|
||||
|
||||
@@ -91,6 +91,7 @@ func TestResellerRepositoryFindActiveVerifiedDomain(t *testing.T) {
|
||||
db := openResellerRepoTestDB(t)
|
||||
profile := seedResellerProfile(t, db, "owner2@example.com")
|
||||
inactiveProfile := seedResellerProfile(t, db, "disabled-owner@example.com")
|
||||
deletedProfile := seedResellerProfile(t, db, "deleted-owner@example.com")
|
||||
if err := db.Model(&inactiveProfile).Update("status", resellerdomain.ProfileStatusDisabled).Error; err != nil {
|
||||
t.Fatalf("disable profile failed: %v", err)
|
||||
}
|
||||
@@ -123,6 +124,19 @@ func TestResellerRepositoryFindActiveVerifiedDomain(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("create disabled profile domain failed: %v", err)
|
||||
}
|
||||
if _, err := repo.UpsertDomain(resellerdomain.Domain{
|
||||
ResellerID: deletedProfile.ID,
|
||||
Domain: "deleted-profile.example.test",
|
||||
Type: resellerdomain.DomainTypeCustom,
|
||||
Status: resellerdomain.DomainStatusActive,
|
||||
VerificationStatus: resellerdomain.DomainVerificationVerified,
|
||||
}); err != nil {
|
||||
t.Fatalf("create deleted profile domain failed: %v", err)
|
||||
}
|
||||
deletedAt := time.Now()
|
||||
if err := db.Model(&deletedProfile).Update("deleted_at", &deletedAt).Error; err != nil {
|
||||
t.Fatalf("soft delete profile failed: %v", err)
|
||||
}
|
||||
disabled, err := repo.FindActiveVerifiedDomain("inactive.example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("lookup disabled failed: %v", err)
|
||||
@@ -137,6 +151,13 @@ func TestResellerRepositoryFindActiveVerifiedDomain(t *testing.T) {
|
||||
if inactiveProfileDomain != nil {
|
||||
t.Fatalf("active domain for disabled profile should not resolve: %+v", inactiveProfileDomain)
|
||||
}
|
||||
deletedProfileDomain, err := repo.FindActiveVerifiedDomain("deleted-profile.example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("lookup deleted profile domain failed: %v", err)
|
||||
}
|
||||
if deletedProfileDomain != nil {
|
||||
t.Fatalf("active domain for soft-deleted profile should not resolve: %+v", deletedProfileDomain)
|
||||
}
|
||||
active, err := repo.FindActiveVerifiedDomain("active.example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("lookup active failed: %v", err)
|
||||
|
||||
Reference in new issue
Block a user