fix(platform): protect role status updates
This commit is contained in:
@@ -4,9 +4,13 @@ go 1.26.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
git.apinb.com/bsm-sdk/core v0.2.0
|
git.apinb.com/bsm-sdk/core v0.2.0
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||||
|
golang.org/x/crypto v0.49.0
|
||||||
|
google.golang.org/grpc v1.81.1
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,16 +64,13 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.27.0 // indirect
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
golang.org/x/arch v0.23.0 // indirect
|
golang.org/x/arch v0.23.0 // indirect
|
||||||
golang.org/x/crypto v0.49.0 // indirect
|
|
||||||
golang.org/x/net v0.52.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/text v0.36.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
google.golang.org/grpc v1.81.1 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gorm.io/driver/mysql v1.6.0 // indirect
|
gorm.io/driver/mysql v1.6.0 // indirect
|
||||||
gorm.io/driver/postgres v1.6.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
git.apinb.com/bsm-sdk/core v0.2.0 h1:/e9yqpsbKBrRgMiGpS3KX2O4qDLxo5V5GpPSLNGxEKw=
|
git.apinb.com/bsm-sdk/core v0.2.0 h1:/e9yqpsbKBrRgMiGpS3KX2O4qDLxo5V5GpPSLNGxEKw=
|
||||||
git.apinb.com/bsm-sdk/core v0.2.0/go.mod h1:E9T6Eboo/0Zb36BjkKbIgvFzq4fQ2Q8P/7y5zmYTI6Y=
|
git.apinb.com/bsm-sdk/core v0.2.0/go.mod h1:E9T6Eboo/0Zb36BjkKbIgvFzq4fQ2Q8P/7y5zmYTI6Y=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
@@ -79,6 +81,7 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
|||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
package platform
|
package platform
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.apinb.com/bsm-sdk/core/errcode"
|
||||||
|
"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) {
|
func TestExpectedResources(t *testing.T) {
|
||||||
@@ -37,6 +51,171 @@ func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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) {
|
func assertContract(t *testing.T, contracts []ResourceContract, domain, name string, mode ResourceMode, pageKind string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, contract := range contracts {
|
for _, contract := range contracts {
|
||||||
|
|||||||
@@ -77,11 +77,7 @@ func UpdatePlatformRoleStatus(ctx *gin.Context) {
|
|||||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := impl.DBService.Model(&role).Update("status", request.Status).Error; err != nil {
|
updateAllowedByIdentity(ctx, &models.PlatformRole{}, gin.H{"status": request.Status}, []string{"status"})
|
||||||
infra.Response.Error(ctx, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
infra.Response.Success(ctx, gin.H{"updated": true})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchivePlatformRole 归档非内置平台角色,系统角色始终受保护。
|
// ArchivePlatformRole 归档非内置平台角色,系统角色始终受保护。
|
||||||
@@ -95,11 +91,7 @@ func ArchivePlatformRole(ctx *gin.Context) {
|
|||||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := impl.DBService.Model(&role).Update("status", "archived").Error; err != nil {
|
updateAllowedByIdentity(ctx, &models.PlatformRole{}, archiveValues(), []string{"status"})
|
||||||
infra.Response.Error(ctx, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
infra.Response.Success(ctx, gin.H{"updated": true})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPlatformMenu 返回菜单树构建所需的有序菜单列表。
|
// ListPlatformMenu 返回菜单树构建所需的有序菜单列表。
|
||||||
|
|||||||
Reference in New Issue
Block a user