fix: harden platform form and response boundaries

This commit is contained in:
2026-07-27 12:19:34 +08:00
parent 34e8b092b3
commit cd58e1f6b6
7 changed files with 294 additions and 67 deletions

View File

@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"reflect"
"regexp"
"strings"
"testing"
@@ -210,7 +211,7 @@ func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalid
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2)))
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","quantity":2}`))
values, err := prepareResourceValues(ctx, []string{"quantity"}, []ResourceRelation{
values, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"quantity"}, []ResourceRelation{
{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true},
{Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true},
})
@@ -224,12 +225,55 @@ func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalid
for _, body := range []string{`{}`, `{"ec_order_id":1}`, `{"quantity":2}`} {
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(body))
if _, err := prepareResourceValues(ctx, []string{"quantity"}, []ResourceRelation{{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}}); err == nil {
if _, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"quantity"}, []ResourceRelation{{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}}); err == nil {
t.Fatalf("payload %s was accepted", body)
}
}
}
func TestPrepareResourceValuesNormalizesStringJSONBFields(t *testing.T) {
t.Run("safety rule", func(t *testing.T) {
ctx, _ := updateContext(http.MethodPost, "/safety/saf_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":{"max":10},"action":"close-valve","gray_scope":["north"]}`))
values, err := prepareResourceValues(ctx, &models.SafRule{}, []string{"rule_code", "threshold", "action", "gray_scope"}, nil)
if err != nil {
t.Fatal(err)
}
if values["threshold"] != `{"max":10}` || values["gray_scope"] != `["north"]` {
t.Fatalf("jsonb values were not normalized to strings: %#v", values)
}
})
t.Run("order item", func(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)).
WithArgs("order-a", 1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1)))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_product" WHERE identity = $1 ORDER BY "ec_product"."id" LIMIT $2`)).
WithArgs("product-a", 1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2)))
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","product_snapshot":{"name":"液化气"},"quantity":2,"sale_amount":500}`))
values, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"product_snapshot", "quantity", "sale_amount"}, []ResourceRelation{
{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true},
{Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true},
})
if err != nil {
t.Fatal(err)
}
if values["product_snapshot"] != `{"name":"液化气"}` {
t.Fatalf("product snapshot was not normalized to a string: %#v", values)
}
assertMockExpectations(t, mock)
})
t.Run("invalid json string", func(t *testing.T) {
ctx, _ := updateContext(http.MethodPost, "/safety/saf_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":"{invalid}","action":"close-valve"}`))
if _, err := prepareResourceValues(ctx, &models.SafRule{}, []string{"rule_code", "threshold", "action"}, nil); err == nil {
t.Fatal("invalid JSON string was accepted for a string/jsonb field")
}
})
}
func TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "gas_basic" WHERE identity = $1 ORDER BY "gas_basic"."id" LIMIT $2`)).
@@ -274,6 +318,26 @@ func TestListGasAccountAppliesKeywordToCountAndRows(t *testing.T) {
assertMockExpectations(t, mock)
}
func TestKeywordColumnsUseSafeTextAllowlist(t *testing.T) {
tests := []struct {
name string
model any
want []string
}{
{"safety rule excludes jsonb", &models.SafRule{}, []string{"rule_code", "action"}},
{"gas basic excludes sensitive fields", &models.GasBasic{}, []string{"code", "name"}},
{"user address has no searchable safe text", &models.UserAddress{}, []string{}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := keywordColumns(test.model)
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("keywordColumns(%T) = %#v, want %#v", test.model, got, test.want)
}
})
}
}
func TestListPlatformMenuReturnsParentIdentityWithoutParentID(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_menu" ORDER BY sort_no asc, id asc`)).
@@ -304,24 +368,30 @@ func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) {
}
}
func TestCreatedResourceResponseMasksSensitiveFieldsAndKeepsIdentity(t *testing.T) {
func TestCreatedResourceResponseUsesSafeAllowlist(t *testing.T) {
response := maskCreatedSensitiveFields(map[string]any{
"identity": "user-a",
"phone": "13800138000",
"real_name": "张三",
"credential_no": "CERT-123456",
"longitude": "120.123456",
"latitude": "30.456789",
"identity": "address-a",
"user_account_identity": "user-a",
"status": "draft",
"version": float64(1),
"phone": "13800138000",
"real_name": "张三",
"credential_no": "CERT-123456",
"address": "敏感详细地址",
"principal": "负责人",
"credit_code": "CREDIT-123",
"longitude": "120.123456",
"latitude": "30.456789",
})
encoded, err := json.Marshal(response)
if err != nil {
t.Fatal(err)
}
body := string(encoded)
if !strings.Contains(body, `"identity":"user-a"`) || !strings.Contains(body, `"phone_masked":"138****8000"`) {
t.Fatalf("created response omitted identity or masked phone: %s", body)
if !strings.Contains(body, `"identity":"address-a"`) || !strings.Contains(body, `"user_account_identity":"user-a"`) || !strings.Contains(body, `"status":"draft"`) {
t.Fatalf("created response omitted safe public fields: %s", body)
}
for _, forbidden := range []string{`"phone":`, `"real_name":`, `"credential_no":`, `"longitude":`, `"latitude":`, "张三", "CERT-123456", "120.123456", "30.456789"} {
for _, forbidden := range []string{`"phone"`, `"phone_masked"`, `"real_name"`, `"credential_no"`, `"address"`, `"principal"`, `"credit_code"`, `"longitude"`, `"latitude"`, "敏感详细地址", "负责人", "CREDIT-123", "120.123456", "30.456789"} {
if strings.Contains(body, forbidden) {
t.Fatalf("created response exposed sensitive field %s: %s", forbidden, body)
}