fix platform workflow integrity and permissions
This commit is contained in:
@@ -42,7 +42,7 @@ func UpdateRecordStatus(ctx *gin.Context, model any) {
|
||||
var request struct {
|
||||
Status int `json:"status" binding:"required"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil || !IsGenericRecordStatus(request.Status) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
@@ -55,7 +55,22 @@ func ArchiveRecord(ctx *gin.Context, model any) {
|
||||
}
|
||||
|
||||
func NewEntity(status int) models.Entity {
|
||||
return models.Entity{Identity: models.NewIdentity(), Status: status, Version: 1}
|
||||
return models.Entity{Identity: models.NewIdentity(), Status: status}
|
||||
}
|
||||
|
||||
// IsGenericRecordStatus reports whether status belongs to the shared record lifecycle.
|
||||
func IsGenericRecordStatus(status int) bool {
|
||||
switch status {
|
||||
case StatusDraft, StatusEnable, StatusDisable, StatusArchived, StatusFrozen:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveRecords excludes logically archived records from operational queries.
|
||||
func ActiveRecords(query *gorm.DB) *gorm.DB {
|
||||
return query.Where("status <> ?", StatusArchived)
|
||||
}
|
||||
|
||||
func ListPage[T any](ctx *gin.Context) {
|
||||
@@ -63,7 +78,7 @@ func ListPage[T any](ctx *gin.Context) {
|
||||
var list []T
|
||||
var total int64
|
||||
model := new(T)
|
||||
databaseQuery := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
|
||||
databaseQuery := ApplyKeywordFilter(ctx, ActiveRecords(impl.DBService.Model(model)), model)
|
||||
if err := databaseQuery.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
|
||||
@@ -37,7 +37,7 @@ func ListResource(ctx *gin.Context, model any) {
|
||||
page, size := PageSize(ctx)
|
||||
list := reflect.New(reflect.SliceOf(reflect.TypeOf(model).Elem()))
|
||||
var total int64
|
||||
query := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
|
||||
query := ApplyKeywordFilter(ctx, ActiveRecords(impl.DBService.Model(model)), model)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
@@ -74,6 +74,10 @@ func createResource(ctx *gin.Context, model any, allowedFields []string, relatio
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if err := ValidateResourceValues(model, values, true); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
encoded, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
@@ -123,7 +127,7 @@ func maskCreatedSensitiveFields(value any) any {
|
||||
|
||||
func isCreatedResponseField(key string) bool {
|
||||
switch key {
|
||||
case "identity", "status", "version", "created_at", "updated_at":
|
||||
case "identity", "status", "created_at", "updated_at":
|
||||
return true
|
||||
default:
|
||||
return strings.HasSuffix(key, "_identity")
|
||||
@@ -226,7 +230,7 @@ func updateResource(ctx *gin.Context, model any, allowedFields []string, relatio
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if len(values) == 0 {
|
||||
if len(values) == 0 || ValidateResourceValues(model, values, false) != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
@@ -245,6 +249,74 @@ func PrepareResourceValues(ctx *gin.Context, model any, allowedFields []string,
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// ValidateResourceValues enforces invariants that database nullability and
|
||||
// frontend form metadata cannot express.
|
||||
func ValidateResourceValues(model any, values map[string]any, creating bool) error {
|
||||
positive := func(key string) bool {
|
||||
value, exists := numericValue(values[key])
|
||||
return !exists || value > 0
|
||||
}
|
||||
nonNegative := func(key string) bool {
|
||||
value, exists := numericValue(values[key])
|
||||
return !exists || value >= 0
|
||||
}
|
||||
nonEmpty := func(key string) bool {
|
||||
value, exists := values[key]
|
||||
if !exists {
|
||||
return !creating
|
||||
}
|
||||
text, ok := value.(string)
|
||||
return ok && strings.TrimSpace(text) != ""
|
||||
}
|
||||
switch model.(type) {
|
||||
case *models.EcProduct:
|
||||
if !nonEmpty("product_code") || !nonEmpty("name") || !nonNegative("price_amount") || !nonNegative("stock_quantity") {
|
||||
return errors.New("invalid commerce product")
|
||||
}
|
||||
case *models.EcCart:
|
||||
if !positive("quantity") {
|
||||
return errors.New("invalid cart quantity")
|
||||
}
|
||||
case *models.EcOrder:
|
||||
if !nonEmpty("order_no") || !nonNegative("total_amount") {
|
||||
return errors.New("invalid order")
|
||||
}
|
||||
case *models.EcOrderItem:
|
||||
if !nonEmpty("product_snapshot") || !positive("quantity") || !nonNegative("sale_amount") {
|
||||
return errors.New("invalid order item")
|
||||
}
|
||||
case *models.EcReview:
|
||||
score, exists := numericValue(values["score"])
|
||||
if (exists && (score < 1 || score > 5)) || !nonEmpty("content") {
|
||||
return errors.New("invalid review")
|
||||
}
|
||||
case *models.FinPayment:
|
||||
if !positive("amount") || !nonEmpty("channel") {
|
||||
return errors.New("invalid payment")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func numericValue(value any) (float64, bool) {
|
||||
switch number := value.(type) {
|
||||
case float64:
|
||||
return number, true
|
||||
case float32:
|
||||
return float64(number), true
|
||||
case int:
|
||||
return float64(number), true
|
||||
case int64:
|
||||
return float64(number), true
|
||||
case uint:
|
||||
return float64(number), true
|
||||
case uint64:
|
||||
return float64(number), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) {
|
||||
values := FilterFields(input, allowedFields)
|
||||
for _, relation := range relations {
|
||||
@@ -286,7 +358,7 @@ func ResolveIdentityID(model any, identity string, required bool) (uint64, error
|
||||
return 0, nil
|
||||
}
|
||||
var related struct{ ID uint64 }
|
||||
if err := impl.DBService.Model(model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil {
|
||||
if err := impl.DBService.Model(model).Select("id").Where("identity = ? AND status <> ?", identity, StatusArchived).First(&related).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return related.ID, nil
|
||||
|
||||
@@ -2,9 +2,13 @@ package common
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) {
|
||||
@@ -14,6 +18,24 @@ func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationalQueriesExcludeArchivedRecords(t *testing.T) {
|
||||
sqlDatabase, _, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sqlDatabase.Close()
|
||||
database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
return ActiveRecords(tx.Model(&models.EcProduct{})).Find(&[]models.EcProduct{})
|
||||
})
|
||||
if !strings.Contains(statement, `status <> 3`) {
|
||||
t.Fatalf("archive filter missing from operational query: %s", statement)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceResponseStripsInternalIDsRecursively(t *testing.T) {
|
||||
got := ResourceResponse(map[string]any{
|
||||
"id": uint64(1), "identity": "root",
|
||||
@@ -56,3 +78,33 @@ func TestCommonMethodModesRemainHTTPCompatible(t *testing.T) {
|
||||
t.Fatal("standard HTTP methods unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericStatusRejectsDomainLifecycleValues(t *testing.T) {
|
||||
for _, status := range []int{StatusDraft, StatusEnable, StatusDisable, StatusArchived, StatusFrozen} {
|
||||
if !IsGenericRecordStatus(status) {
|
||||
t.Fatalf("generic status %d was rejected", status)
|
||||
}
|
||||
}
|
||||
if IsGenericRecordStatus(StatusCompleted) {
|
||||
t.Fatal("business lifecycle status was accepted as generic entity status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommerceAndFinanceRejectInvalidAmounts(t *testing.T) {
|
||||
tests := []struct {
|
||||
model any
|
||||
values map[string]any
|
||||
}{
|
||||
{&models.EcProduct{}, map[string]any{"product_code": "p", "name": "P", "price_amount": -1.0}},
|
||||
{&models.EcCart{}, map[string]any{"quantity": 0.0}},
|
||||
{&models.EcOrder{}, map[string]any{"order_no": "o", "total_amount": -1.0}},
|
||||
{&models.EcOrderItem{}, map[string]any{"product_snapshot": "{}", "quantity": -1.0, "sale_amount": 1.0}},
|
||||
{&models.EcReview{}, map[string]any{"score": 6.0, "content": "bad"}},
|
||||
{&models.FinPayment{}, map[string]any{"channel": "wallet", "amount": -1.0}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if ValidateResourceValues(test.model, test.values, true) == nil {
|
||||
t.Fatalf("%T accepted invalid values %#v", test.model, test.values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,18 +38,3 @@ const (
|
||||
StatusSuccess = 37 // 成功
|
||||
StatusMatched = 38 // 已匹配
|
||||
)
|
||||
|
||||
var statusNames = map[int]string{
|
||||
StatusDraft: "draft", StatusEnable: "enabled", StatusDisable: "disabled", StatusArchived: "archived", StatusFrozen: "frozen",
|
||||
StatusPending: "pending", StatusActive: "active", StatusExpired: "expired", StatusTerminated: "terminated",
|
||||
StatusRecorded: "recorded", StatusBound: "bound", StatusCreated: "created", StatusOrdered: "ordered",
|
||||
StatusAssigned: "assigned", StatusFilling: "filling", StatusReady: "ready", StatusException: "exception",
|
||||
StatusCancelled: "cancelled", StatusCompleted: "completed", StatusPosted: "posted", StatusApproved: "approved",
|
||||
StatusRejected: "rejected", StatusScrapped: "scrapped", StatusInStock: "in_stock", StatusInTransit: "in_transit",
|
||||
StatusInUse: "in_use", StatusRepairing: "repairing", StatusOpen: "open", StatusDelivering: "delivering",
|
||||
StatusAwaitingConfirmation: "awaiting_confirmation",
|
||||
StatusPaid: "paid", StatusPublished: "published", StatusSuccess: "success", StatusMatched: "matched",
|
||||
}
|
||||
|
||||
// StatusName 返回状态整数对应的稳定英文名称,供审计快照字段使用。
|
||||
func StatusName(status int) string { return statusNames[status] }
|
||||
|
||||
Reference in New Issue
Block a user