diff --git a/backend/api/internal/logic/platform/platform.go b/backend/api/internal/logic/platform/platform.go index 559442d..5235a8c 100644 --- a/backend/api/internal/logic/platform/platform.go +++ b/backend/api/internal/logic/platform/platform.go @@ -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) { diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index 547d8fb..f970de7 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -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) diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go index fe447b8..e90c3de 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -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 { diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index 8b8a2b1..2f54eee 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -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;