Files
platforms/backend/api/internal/logic/platform/resource_test.go

266 lines
9.7 KiB
Go

package platform
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"regexp"
"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) {
assertContract(t, ExpectedResources(), "gas", "gas_basic", Writable, "list")
assertContract(t, ExpectedResources(), "device", "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 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)
}