package platform import ( "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "regexp" "strings" "testing" "time" "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/types" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" "google.golang.org/grpc/status" "gorm.io/driver/postgres" "gorm.io/gorm" ) func TestExpectedResources(t *testing.T) { assertContract(t, ExpectedResources(), "gas", "gas_basic", Writable, "list") assertContract(t, ExpectedResources(), "safety", "saf_event", Writable, "list") assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list") } func TestResourceDefinitionAllowsOnlySupportedMethods(t *testing.T) { if (ResourceDefinition{Mode: ReadOnly}).Allows(http.MethodPost) { t.Fatal("readonly allows POST") } if !(ResourceDefinition{Mode: AppendOnly}).Allows(http.MethodPost) { t.Fatal("append-only rejects POST") } if (ResourceDefinition{Mode: AppendOnly}).Allows(http.MethodDelete) { t.Fatal("append-only allows DELETE") } } func TestArchiveValuesOnlyArchives(t *testing.T) { got := archiveValues() if len(got) != 1 || got["status"] != "archived" { t.Fatalf("unexpected archive values: %#v", got) } } func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) { got := filterFields(map[string]any{"name": "n", "password_hash": "x"}, []string{"name"}) if len(got) != 1 || got["name"] != "n" { t.Fatalf("unexpected filtered fields: %#v", got) } } func TestResourceDefinitionAllowsOnlyMethodsForEachMode(t *testing.T) { tests := []struct { name string mode ResourceMode method string want bool }{ {"readonly GET", ReadOnly, http.MethodGet, true}, {"readonly POST", ReadOnly, http.MethodPost, false}, {"append-only GET", AppendOnly, http.MethodGet, true}, {"append-only POST", AppendOnly, http.MethodPost, true}, {"append-only PUT", AppendOnly, http.MethodPut, false}, {"writable GET", Writable, http.MethodGet, true}, {"writable POST", Writable, http.MethodPost, true}, {"writable PUT", Writable, http.MethodPut, true}, {"writable PATCH", Writable, http.MethodPatch, true}, {"writable DELETE", Writable, http.MethodDelete, true}, {"writable OPTIONS", Writable, http.MethodOptions, false}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if got := (ResourceDefinition{Mode: test.mode}).Allows(test.method); got != test.want { t.Fatalf("Allows(%s) = %t, want %t", test.method, got, test.want) } }) } } func TestUpdateAllowedByIdentityFiltersUnknownFieldsAndUsesIdentity(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectBegin() mock.ExpectExec(regexp.QuoteMeta(`UPDATE "platform_role" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)). WithArgs("disabled", sqlmock.AnyArg(), "role-a"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() ctx, recorder := updateContext(http.MethodPatch, "/roles/role-a", "role-a", nil) updateAllowedByIdentity(ctx, &models.PlatformRole{}, gin.H{"status": "disabled", "is_system": true}, []string{"status"}) assertResponseCode(t, recorder, 0) assertMockExpectations(t, mock) } func TestUpdateAllowedByIdentityReturnsNotFoundForZeroRows(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectBegin() mock.ExpectExec(regexp.QuoteMeta(`UPDATE "platform_role" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)). WithArgs("disabled", sqlmock.AnyArg(), "missing"). WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectCommit() ctx, recorder := updateContext(http.MethodPatch, "/roles/missing", "missing", nil) updateAllowedByIdentity(ctx, &models.PlatformRole{}, gin.H{"status": "disabled"}, []string{"status"}) assertResponseCode(t, recorder, int32(status.Code(errcode.ErrRecordNotFound))) assertMockExpectations(t, mock) } func TestUpdateAllowedByIdentityReturnsUniformResponseForDatabaseError(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectBegin() mock.ExpectExec(regexp.QuoteMeta(`UPDATE "platform_role" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)). WithArgs("disabled", sqlmock.AnyArg(), "role-a"). WillReturnError(errors.New("database unavailable")) mock.ExpectRollback() ctx, recorder := updateContext(http.MethodPatch, "/roles/role-a", "role-a", nil) updateAllowedByIdentity(ctx, &models.PlatformRole{}, gin.H{"status": "disabled"}, []string{"status"}) var reply responseBody if err := json.Unmarshal(recorder.Body.Bytes(), &reply); err != nil { t.Fatal(err) } if reply.Code != 500 || reply.Message == "" || string(reply.Details) != `""` { t.Fatalf("database error did not use uniform response: %s", recorder.Body.String()) } } func TestPlatformRoleStatusAndArchiveReturnNotFoundWhenUpdateAffectsZeroRows(t *testing.T) { for _, test := range []struct { name string handler gin.HandlerFunc method string body string status string }{ {"status", UpdatePlatformRoleStatus, http.MethodPatch, `{"status":"disabled"}`, "disabled"}, {"archive", ArchivePlatformRole, http.MethodDelete, "", "archived"}, } { t.Run(test.name, func(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE identity = $1 ORDER BY "platform_role"."id" LIMIT $2`)). WithArgs("role-a", 1). WillReturnRows(platformRoleRows("role-a")) mock.ExpectBegin() mock.ExpectExec(regexp.QuoteMeta(`UPDATE "platform_role" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)). WithArgs(test.status, sqlmock.AnyArg(), "role-a"). WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectCommit() ctx, recorder := updateContext(test.method, "/roles/role-a", "role-a", []byte(test.body)) test.handler(ctx) assertResponseCode(t, recorder, int32(status.Code(errcode.ErrRecordNotFound))) assertMockExpectations(t, mock) }) } } func TestGetEcOrderReturnsOrderItems(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)). WithArgs("order-a", 1). WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "order_no", "user_account_id", "gas_station_id", "delivery_point_id", "total_amount"}). AddRow(uint64(1), "order-a", nil, nil, "enabled", 1, "O-1", uint64(2), uint64(3), uint64(4), int64(500))) mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "ec_order_item" WHERE ec_order_id = $1 ORDER BY id asc`)). 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))) 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"`) { t.Fatalf("order detail omitted its items: %s", recorder.Body.String()) } assertMockExpectations(t, mock) } func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) now := time.Now().UTC() mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track" WHERE identity = $1 ORDER BY "delivery_track"."id" LIMIT $2`)). WithArgs("track-a", 1). WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_task_id", "started_at", "completed_at"}). AddRow(uint64(7), "track-a", nil, nil, "enabled", 1, uint64(8), now, nil)) mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" WHERE delivery_track_id = $1 ORDER BY occurred_at asc`)). 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")) ctx, recorder := updateContext(http.MethodGet, "/delivery/delivery_track/track-a", "track-a", nil) GetDeliveryTrack(ctx) assertResponseCode(t, recorder, 0) if strings.Contains(recorder.Body.String(), "120.123") || strings.Contains(recorder.Body.String(), "30.456") { t.Fatalf("unauthorized response exposed precise coordinates: %s", recorder.Body.String()) } assertMockExpectations(t, mock) } func TestDisposeSafetyEventUpdatesEventAndAppendsOperatorActionTransactionally(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) now := time.Now().UTC() mock.ExpectBegin() mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "saf_event" WHERE identity = $1 ORDER BY "saf_event"."id" LIMIT $2`)). WithArgs("event-a", 1). WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "event_code", "level", "title", "smart_cylinder_valve_identity", "sla_at"}). AddRow(uint64(3), "event-a", now, now, "open", 1, "E-1", 2, "alarm", "valve-a", nil)) mock.ExpectExec(regexp.QuoteMeta(`UPDATE "saf_event" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)). WithArgs("disposed", sqlmock.AnyArg(), "event-a"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "saf_event_disposal" ("identity","created_at","updated_at","status","version","saf_event_identity","action","reason","operator_identity") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING "id"`)). WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "enabled", 1, "event-a", "close", "resolved", "operator-a"). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1))) mock.ExpectCommit() ctx, recorder := updateContext(http.MethodPost, "/safety/saf_event/event-a/disposals", "event-a", []byte(`{"action":"close","reason":"resolved"}`)) ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"}) DisposeSafetyEvent(ctx) assertResponseCode(t, recorder, 0) if !strings.Contains(recorder.Body.String(), `"operator_identity":"operator-a"`) { t.Fatalf("disposal omitted its operator: %s", recorder.Body.String()) } assertMockExpectations(t, mock) } func TestReplacePlatformRoleMenusAllowsAnEmptySetToClearAssignmentsTransactionally(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectBegin() mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE identity = $1 ORDER BY "platform_role"."id" LIMIT $2`)). WithArgs("role-a", 1). WillReturnRows(platformRoleRows("role-a")) mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM "platform_role_menu_relation" WHERE platform_role_id = $1`)). WithArgs(uint64(1)). WillReturnResult(sqlmock.NewResult(0, 2)) mock.ExpectCommit() ctx, recorder := updateContext(http.MethodPut, "/roles/role-a/menus", "role-a", []byte(`{"menu_identities":[]}`)) ReplacePlatformRoleMenus(ctx) assertResponseCode(t, recorder, 0) assertMockExpectations(t, mock) } func TestReplacePlatformRoleMenusRejectsSystemRoleBeforeChangingRelations(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectBegin() mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE identity = $1 ORDER BY "platform_role"."id" LIMIT $2`)). WithArgs("root-role", 1). WillReturnRows(platformSystemRoleRows("root-role")) mock.ExpectRollback() ctx, recorder := updateContext(http.MethodPut, "/roles/root-role/menus", "root-role", []byte(`{"menu_identities":[]}`)) ReplacePlatformRoleMenus(ctx) assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument))) assertMockExpectations(t, mock) } type responseBody struct { Code int32 `json:"code"` Message string `json:"message"` Details json.RawMessage `json:"details"` } func setupPlatformRoleDatabase(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) { t.Helper() previous := impl.DBService sqlDatabase, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{}) if err != nil { t.Fatal(err) } impl.DBService = database t.Cleanup(func() { impl.DBService = previous _ = sqlDatabase.Close() }) return database, mock } func platformRoleRows(identity string) *sqlmock.Rows { return sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "role_code", "name", "data_scope", "is_system"}). AddRow(uint64(1), identity, nil, nil, "enabled", 1, identity, identity, "global", false) } func platformSystemRoleRows(identity string) *sqlmock.Rows { return sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "role_code", "name", "data_scope", "is_system"}). AddRow(uint64(1), identity, nil, nil, "enabled", 1, identity, identity, "global", true) } func updateContext(method, target, identity string, body []byte) (*gin.Context, *httptest.ResponseRecorder) { recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest(method, target, bytes.NewReader(body)) ctx.Request.Header.Set("Content-Type", "application/json") ctx.Params = gin.Params{{Key: "identity", Value: identity}} return ctx, recorder } func assertResponseCode(t *testing.T, recorder *httptest.ResponseRecorder, want int32) { t.Helper() var reply responseBody if err := json.Unmarshal(recorder.Body.Bytes(), &reply); err != nil { t.Fatal(err) } if reply.Code != want { t.Fatalf("response code = %d, want %d: %s", reply.Code, want, recorder.Body.String()) } } func assertMockExpectations(t *testing.T, mock sqlmock.Sqlmock) { t.Helper() if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func assertContract(t *testing.T, contracts []ResourceContract, domain, name string, mode ResourceMode, pageKind string) { t.Helper() for _, contract := range contracts { if contract.Domain == domain && contract.Name == name && contract.Mode == mode && contract.PageKind == pageKind { return } } t.Fatalf("missing resource contract %s/%s with mode %q and page kind %q", domain, name, mode, pageKind) }