fix: harden platform response projections
This commit is contained in:
@@ -47,7 +47,12 @@ func listPage[T any](ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": publicResourceResponse(list)})
|
||||
response, err := publicResourceResponse(list)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": response})
|
||||
}
|
||||
|
||||
func getByIdentity[T any](ctx *gin.Context) {
|
||||
@@ -56,7 +61,12 @@ func getByIdentity[T any](ctx *gin.Context) {
|
||||
respondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, publicResourceResponse(data))
|
||||
response, err := publicResourceResponse(data)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, response)
|
||||
}
|
||||
|
||||
func updateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) {
|
||||
|
||||
@@ -173,12 +173,27 @@ func TestGetEcOrderReturnsOrderItems(t *testing.T) {
|
||||
WithArgs(uint64(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "ec_order_id", "ec_product_id", "product_snapshot", "quantity", "sale_amount"}).
|
||||
AddRow(uint64(2), "item-a", nil, nil, "enabled", 1, uint64(1), uint64(5), `{}`, 2, int64(500)))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "delivery_basic" WHERE id IN ($1)`)).
|
||||
WithArgs(uint64(4)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(4), "delivery-a"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "ec_order" WHERE id IN ($1)`)).
|
||||
WithArgs(uint64(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(1), "order-a"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "ec_product" WHERE id IN ($1)`)).
|
||||
WithArgs(uint64(5)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(5), "product-a"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1)`)).
|
||||
WithArgs(uint64(3)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(3), "gas-a"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "user_account" WHERE id IN ($1)`)).
|
||||
WithArgs(uint64(2)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(2), "user-a"))
|
||||
|
||||
ctx, recorder := updateContext(http.MethodGet, "/ec/ec_order/order-a", "order-a", nil)
|
||||
GetEcOrder(ctx)
|
||||
|
||||
assertResponseCode(t, recorder, 0)
|
||||
if !strings.Contains(recorder.Body.String(), `"items"`) || !strings.Contains(recorder.Body.String(), `"item-a"`) {
|
||||
if !strings.Contains(recorder.Body.String(), `"items"`) || !strings.Contains(recorder.Body.String(), `"item-a"`) || strings.Contains(recorder.Body.String(), `_id"`) {
|
||||
t.Fatalf("order detail omitted its items: %s", recorder.Body.String())
|
||||
}
|
||||
assertMockExpectations(t, mock)
|
||||
@@ -269,9 +284,9 @@ func TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID(t *test
|
||||
WithArgs(20).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"}).
|
||||
AddRow(uint64(9), "account-a", nil, nil, "enabled", 1, uint64(7), "operator", "Operator", "hash", "admin"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "identity" FROM "gas_basic" WHERE id = $1 ORDER BY "gas_basic"."id" LIMIT $2`)).
|
||||
WithArgs(uint64(7), 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity"}).AddRow("gas-a"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1)`)).
|
||||
WithArgs(uint64(7)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "gas-a"))
|
||||
|
||||
ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account", "", nil)
|
||||
ListGasAccount(ctx)
|
||||
@@ -284,6 +299,51 @@ func TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID(t *test
|
||||
assertMockExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestListGasAccountPreloadsRelationIdentitiesInOneQuery(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account" ORDER BY created_at desc LIMIT $1`)).
|
||||
WithArgs(20).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"}).
|
||||
AddRow(uint64(9), "account-a", nil, nil, "enabled", 1, uint64(7), "operator-a", "Operator A", "hash", "admin").
|
||||
AddRow(uint64(10), "account-b", nil, nil, "enabled", 1, uint64(8), "operator-b", "Operator B", "hash", "admin"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1,$2)`)).
|
||||
WithArgs(uint64(7), uint64(8)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "gas-a").AddRow(uint64(8), "gas-b"))
|
||||
|
||||
ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account", "", nil)
|
||||
ListGasAccount(ctx)
|
||||
|
||||
assertResponseCode(t, recorder, 0)
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, `"gas_basic_identity":"gas-a"`) || !strings.Contains(body, `"gas_basic_identity":"gas-b"`) {
|
||||
t.Fatalf("account list omitted preloaded relation identities: %s", body)
|
||||
}
|
||||
assertMockExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestListGasAccountFailsWhenRelationIdentityProjectionCannotLoad(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account" ORDER BY created_at desc LIMIT $1`)).
|
||||
WithArgs(20).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"}).
|
||||
AddRow(uint64(9), "account-a", nil, nil, "enabled", 1, uint64(7), "operator", "Operator", "hash", "admin"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1)`)).
|
||||
WithArgs(uint64(7)).
|
||||
WillReturnError(errors.New("relation lookup unavailable"))
|
||||
|
||||
ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account", "", nil)
|
||||
ListGasAccount(ctx)
|
||||
|
||||
if strings.Contains(recorder.Body.String(), `"code":0`) {
|
||||
t.Fatalf("relation projection failure was returned as success: %s", recorder.Body.String())
|
||||
}
|
||||
assertMockExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
now := time.Now().UTC()
|
||||
@@ -295,6 +355,12 @@ func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *test
|
||||
WithArgs(uint64(7)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_track_id", "point_type", "occurred_at", "longitude", "latitude"}).
|
||||
AddRow(uint64(9), "point-a", nil, nil, "enabled", 1, uint64(7), "arrival", now, "120.123", "30.456"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "delivery_task" WHERE id IN ($1)`)).
|
||||
WithArgs(uint64(8)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(8), "task-a"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "delivery_track" WHERE id IN ($1)`)).
|
||||
WithArgs(uint64(7)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "track-a"))
|
||||
|
||||
ctx, recorder := updateContext(http.MethodGet, "/delivery/delivery_track/track-a", "track-a", nil)
|
||||
GetDeliveryTrack(ctx)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
@@ -102,7 +103,12 @@ func listResource(ctx *gin.Context, model any) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": publicResourceResponse(list.Elem().Interface())})
|
||||
response, err := publicResourceResponse(list.Elem().Interface())
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": response})
|
||||
}
|
||||
|
||||
func getResource(ctx *gin.Context, model any) {
|
||||
@@ -111,7 +117,12 @@ func getResource(ctx *gin.Context, model any) {
|
||||
respondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, publicResourceResponse(data.Interface()))
|
||||
response, err := publicResourceResponse(data.Interface())
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, response)
|
||||
}
|
||||
|
||||
func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
|
||||
@@ -233,14 +244,14 @@ func resourceResponse(value any) any {
|
||||
// publicResourceResponse additionally resolves persisted relation keys into
|
||||
// their public identities. It is used by list/detail endpoints so an edit form
|
||||
// can round-trip the relation without ever receiving a surrogate database ID.
|
||||
func publicResourceResponse(value any) any {
|
||||
func publicResourceResponse(value any) (any, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return value
|
||||
return nil, err
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
return value
|
||||
return nil, err
|
||||
}
|
||||
return projectRelationIdentities(decoded)
|
||||
}
|
||||
@@ -269,7 +280,54 @@ var relationIdentityKeys = map[string]string{
|
||||
"delivery_point_id": "delivery_basic_identity",
|
||||
}
|
||||
|
||||
func projectRelationIdentities(value any) any {
|
||||
type relationIdentityReference struct {
|
||||
target map[string]any
|
||||
identityKey string
|
||||
id uint64
|
||||
}
|
||||
|
||||
type relationIdentityGroup struct {
|
||||
model any
|
||||
ids []uint64
|
||||
seen map[uint64]struct{}
|
||||
references []relationIdentityReference
|
||||
}
|
||||
|
||||
type relationIdentityRecord struct {
|
||||
ID uint64
|
||||
Identity string
|
||||
}
|
||||
|
||||
func projectRelationIdentities(value any) (any, error) {
|
||||
groups := map[string]*relationIdentityGroup{}
|
||||
collectRelationIdentityReferences(value, groups)
|
||||
groupKeys := make([]string, 0, len(groups))
|
||||
for key := range groups {
|
||||
groupKeys = append(groupKeys, key)
|
||||
}
|
||||
sort.Strings(groupKeys)
|
||||
for _, key := range groupKeys {
|
||||
group := groups[key]
|
||||
var rows []relationIdentityRecord
|
||||
if err := impl.DBService.Model(group.model).Select("id", "identity").Where("id IN ?", group.ids).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identities := make(map[uint64]string, len(rows))
|
||||
for _, row := range rows {
|
||||
identities[row.ID] = row.Identity
|
||||
}
|
||||
for _, reference := range group.references {
|
||||
identity, found := identities[reference.id]
|
||||
if !found {
|
||||
return nil, errors.New("related identity not found")
|
||||
}
|
||||
reference.target[reference.identityKey] = identity
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func collectRelationIdentityReferences(value any, groups map[string]*relationIdentityGroup) {
|
||||
switch data := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range data {
|
||||
@@ -288,22 +346,35 @@ func projectRelationIdentities(value any) any {
|
||||
identityKey = "subject_identity"
|
||||
}
|
||||
if model != nil {
|
||||
data[identityKey] = relationIdentity(item, model)
|
||||
if id, ok := responseRelationID(item); ok && id != 0 {
|
||||
key := reflect.TypeOf(model).String()
|
||||
group := groups[key]
|
||||
if group == nil {
|
||||
group = &relationIdentityGroup{model: model, seen: map[uint64]struct{}{}}
|
||||
groups[key] = group
|
||||
}
|
||||
if _, found := group.seen[id]; !found {
|
||||
group.ids = append(group.ids, id)
|
||||
group.seen[id] = struct{}{}
|
||||
}
|
||||
group.references = append(group.references, relationIdentityReference{target: data, identityKey: identityKey, id: id})
|
||||
} else {
|
||||
data[identityKey] = ""
|
||||
}
|
||||
}
|
||||
delete(data, key)
|
||||
continue
|
||||
}
|
||||
data[key] = projectRelationIdentities(item)
|
||||
collectRelationIdentityReferences(item, groups)
|
||||
}
|
||||
case []any:
|
||||
for index := range data {
|
||||
data[index] = projectRelationIdentities(data[index])
|
||||
for _, item := range data {
|
||||
collectRelationIdentityReferences(item, groups)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func relationIdentity(value any, model any) string {
|
||||
func responseRelationID(value any) (uint64, bool) {
|
||||
var id uint64
|
||||
switch raw := value.(type) {
|
||||
case float64:
|
||||
@@ -312,15 +383,10 @@ func relationIdentity(value any, model any) string {
|
||||
id = raw
|
||||
case int:
|
||||
id = uint64(raw)
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
var related struct{ Identity string }
|
||||
if err := impl.DBService.Model(model).Select("identity").Where("id = ?", id).First(&related).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return related.Identity
|
||||
return id, true
|
||||
}
|
||||
|
||||
func settlementSubjectModel(value any) any {
|
||||
@@ -418,7 +484,12 @@ func GetEcOrder(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, publicResourceResponse(gin.H{"order": order, "items": items}))
|
||||
response, err := publicResourceResponse(gin.H{"order": order, "items": items})
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, response)
|
||||
}
|
||||
|
||||
// GetDeliveryTrack returns time-ordered points. Precise coordinates are only
|
||||
@@ -444,7 +515,12 @@ func GetDeliveryTrack(ctx *gin.Context) {
|
||||
points[index].Latitude = ""
|
||||
}
|
||||
}
|
||||
infra.Response.Success(ctx, publicResourceResponse(gin.H{"track": track, "points": points}))
|
||||
response, err := publicResourceResponse(gin.H{"track": track, "points": points})
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, response)
|
||||
}
|
||||
|
||||
type ecCategoryView struct {
|
||||
|
||||
@@ -29,11 +29,25 @@ for (const contract of expected) {
|
||||
if (resource.fields.length === 0 || resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id'))) failures.push(`${contract.name}: invalid allowlist`);
|
||||
const method = resource.mode === 'append_only' ? 'POST' : 'GET';
|
||||
if (!manifest.routes.some((route) => route.method === method && route.path === resource.resource)) failures.push(`${contract.name}: resource route mismatch`);
|
||||
if (resource.mode === 'readonly') { const view = files('src/views').find((file) => file.includes(resource.name) && file.endsWith('ListPage.vue')); if (!view || !fs.readFileSync(view, 'utf8').includes('ReadOnlyListPage')) failures.push(`${contract.name}: readonly UI`); }
|
||||
if (resource.mode === 'readonly') {
|
||||
const view = files('src/views').find((file) => file.includes(resource.name) && file.endsWith('ListPage.vue'));
|
||||
if (!view || !fs.readFileSync(view, 'utf8').includes('ReadOnlyListPage')) failures.push(`${contract.name}: readonly UI`);
|
||||
}
|
||||
}
|
||||
if (JSON.stringify(resources.map((item) => item.name).sort()) !== JSON.stringify(expected.map((item) => item.name).sort())) failures.push('catalogue names differ from backend ExpectedResources');
|
||||
for (const name of ['ec_category', 'platform_menu']) { const view = files('src/views').find((file) => file.includes(name) && file.endsWith('TreePage.vue')); if (!view) failures.push(`${name}: tree page missing`); }
|
||||
const tree = fs.readFileSync('src/views/shared/TreePage.vue', 'utf8');
|
||||
if (!tree.includes('parent_identity') || !tree.includes('canWrite') || !tree.includes('resourceApi.create') || !tree.includes('resourceApi.update')) failures.push('tree: writable identity actions missing');
|
||||
if (/(?:['"`]id['"`]|\bparent_id\b|\.id\b)/.test(tree)) failures.push('tree: internal id key leaked');
|
||||
const readOnlyPage = fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8');
|
||||
if (/resourceApi\.(?:create|update|archive)\b/.test(readOnlyPage)) failures.push('readonly: mutation action exposed');
|
||||
for (const detailPage of ['src/views/shared/CrudListPage.vue', 'src/views/shared/ReadOnlyListPage.vue']) {
|
||||
const source = fs.readFileSync(detailPage, 'utf8');
|
||||
if (!source.includes("key !== 'id'") || !source.includes("key.endsWith('_id')")) failures.push(`${detailPage}: internal ids shown in detail`);
|
||||
}
|
||||
const event = resources.find((resource) => resource.name === 'saf_event');
|
||||
const disposal = resources.find((resource) => resource.name === 'saf_event_disposal');
|
||||
if (!event?.detailActions?.some((action) => action.name === 'saf_event_disposal' && action.resource.includes(':identity/disposals'))) failures.push('saf_event_disposal: detail action missing');
|
||||
if (!disposal || disposal.resource !== '/safety/saf_event/:identity/disposals' || files('src/views').some((file) => file.includes('saf_event_disposal'))) failures.push('saf_event_disposal: independent page exposed');
|
||||
for (const failure of failures) console.log(failure);
|
||||
if (failures.length) process.exitCode = 1;
|
||||
|
||||
Reference in New Issue
Block a user