931 lines
44 KiB
Go
931 lines
44 KiB
Go
package platform
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"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(), "ec", "ec_order_item", Writable, "list")
|
|
assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list")
|
|
assertContract(t, ExpectedResources(), "delivery", "delivery_track_point", 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))
|
|
ctx.Set("Auth", &types.JwtClaims{Role: "root"})
|
|
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)))
|
|
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"`) || strings.Contains(recorder.Body.String(), `_id"`) {
|
|
t.Fatalf("order detail omitted its items: %s", recorder.Body.String())
|
|
}
|
|
assertMockExpectations(t, mock)
|
|
}
|
|
|
|
func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalidPayloads(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)).
|
|
WithArgs("order-a", 1).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1)))
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_product" WHERE identity = $1 ORDER BY "ec_product"."id" LIMIT $2`)).
|
|
WithArgs("product-a", 1).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2)))
|
|
|
|
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","quantity":2}`))
|
|
values, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"quantity"}, []ResourceRelation{
|
|
{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true},
|
|
{Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if values["ec_order_id"] != uint64(1) || values["ec_product_id"] != uint64(2) || values["quantity"] != float64(2) {
|
|
t.Fatalf("unexpected resolved values: %#v", values)
|
|
}
|
|
assertMockExpectations(t, mock)
|
|
|
|
for _, body := range []string{`{}`, `{"ec_order_id":1}`, `{"quantity":2}`} {
|
|
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(body))
|
|
if _, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"quantity"}, []ResourceRelation{{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}}); err == nil {
|
|
t.Fatalf("payload %s was accepted", body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPrepareResourceValuesNormalizesStringJSONBFields(t *testing.T) {
|
|
t.Run("safety rule", func(t *testing.T) {
|
|
ctx, _ := updateContext(http.MethodPost, "/safety/saf_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":{"max":10},"action":"close-valve","gray_scope":["north"]}`))
|
|
values, err := prepareResourceValues(ctx, &models.SafRule{}, []string{"rule_code", "threshold", "action", "gray_scope"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if values["threshold"] != `{"max":10}` || values["gray_scope"] != `["north"]` {
|
|
t.Fatalf("jsonb values were not normalized to strings: %#v", values)
|
|
}
|
|
})
|
|
|
|
t.Run("order item", func(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)).
|
|
WithArgs("order-a", 1).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1)))
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_product" WHERE identity = $1 ORDER BY "ec_product"."id" LIMIT $2`)).
|
|
WithArgs("product-a", 1).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2)))
|
|
|
|
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","product_snapshot":{"name":"液化气"},"quantity":2,"sale_amount":500}`))
|
|
values, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"product_snapshot", "quantity", "sale_amount"}, []ResourceRelation{
|
|
{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true},
|
|
{Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if values["product_snapshot"] != `{"name":"液化气"}` {
|
|
t.Fatalf("product snapshot was not normalized to a string: %#v", values)
|
|
}
|
|
assertMockExpectations(t, mock)
|
|
})
|
|
|
|
t.Run("invalid json string", func(t *testing.T) {
|
|
ctx, _ := updateContext(http.MethodPost, "/safety/saf_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":"{invalid}","action":"close-valve"}`))
|
|
if _, err := prepareResourceValues(ctx, &models.SafRule{}, []string{"rule_code", "threshold", "action"}, nil); err == nil {
|
|
t.Fatal("invalid JSON string was accepted for a string/jsonb field")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "gas_basic" WHERE identity = $1 ORDER BY "gas_basic"."id" LIMIT $2`)).
|
|
WithArgs("gas-a", 1).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(8)))
|
|
mock.ExpectBegin()
|
|
mock.ExpectQuery(`INSERT INTO "gas_account"`).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1)))
|
|
mock.ExpectCommit()
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1)`)).
|
|
WithArgs(uint64(8)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(8), "gas-a"))
|
|
|
|
ctx, recorder := updateContext(http.MethodPost, "/gas/gas_account", "", []byte(`{"username":"operator","password":"password-123","gas_basic_identity":"gas-a"}`))
|
|
CreateGasAccount(ctx)
|
|
|
|
assertResponseCode(t, recorder, 0)
|
|
body := recorder.Body.String()
|
|
if !strings.Contains(body, `"identity"`) || !strings.Contains(body, `"gas_basic_identity":"gas-a"`) {
|
|
t.Fatalf("create response omitted public identities: %s", body)
|
|
}
|
|
if strings.Contains(body, `"gas_basic_id"`) || strings.Contains(body, `"password_hash"`) {
|
|
t.Fatalf("create response exposed internal or sensitive fields: %s", body)
|
|
}
|
|
assertMockExpectations(t, mock)
|
|
}
|
|
|
|
func TestListGasAccountAppliesKeywordToCountAndRows(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
keywordWhere := ` WHERE (LOWER("username") LIKE $1 OR LOWER("display_name") LIKE $2 OR LOWER("role_code") LIKE $3)`
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`+keywordWhere)).
|
|
WithArgs("%operator%", "%operator%", "%operator%").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account"`+keywordWhere+` ORDER BY created_at desc LIMIT $4`)).
|
|
WithArgs("%operator%", "%operator%", "%operator%", 20).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"}))
|
|
|
|
ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account?keyword=Operator", "", nil)
|
|
ListGasAccount(ctx)
|
|
|
|
assertResponseCode(t, recorder, 0)
|
|
assertMockExpectations(t, mock)
|
|
}
|
|
|
|
func TestKeywordColumnsUseSafeTextAllowlist(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
model any
|
|
want []string
|
|
}{
|
|
{"safety rule excludes jsonb", &models.SafRule{}, []string{"rule_code", "action"}},
|
|
{"gas basic excludes sensitive fields", &models.GasBasic{}, []string{"code", "name"}},
|
|
{"user address has no searchable safe text", &models.UserAddress{}, []string{}},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got := keywordColumns(test.model)
|
|
if !reflect.DeepEqual(got, test.want) {
|
|
t.Fatalf("keywordColumns(%T) = %#v, want %#v", test.model, got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListPlatformMenuReturnsParentIdentityWithoutParentID(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_menu" ORDER BY sort_no asc, id asc`)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "parent_id", "menu_code", "name", "icon", "path", "sort_no"}).
|
|
AddRow(uint64(1), "root-a", nil, nil, "enabled", 1, uint64(0), "root", "Root", "", "", 1).
|
|
AddRow(uint64(2), "child-a", nil, nil, "enabled", 1, uint64(1), "child", "Child", "", "/child", 2))
|
|
|
|
ctx, recorder := updateContext(http.MethodGet, "/platform/platform_menu", "", nil)
|
|
ctx.Set("Auth", &types.JwtClaims{Role: "root"})
|
|
ListPlatformMenu(ctx)
|
|
|
|
assertResponseCode(t, recorder, 0)
|
|
if !strings.Contains(recorder.Body.String(), `"parent_identity":"root-a"`) || strings.Contains(recorder.Body.String(), `"parent_id"`) {
|
|
t.Fatalf("menu response leaked parent_id or omitted identity: %s", recorder.Body.String())
|
|
}
|
|
assertMockExpectations(t, mock)
|
|
}
|
|
|
|
func TestListPlatformMenuReturnsOnlyMenusAssignedToNonRootRole(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE role_code = $1 AND status = $2 ORDER BY "platform_role"."id" LIMIT $3`)).
|
|
WithArgs("finance_operator", "enabled", 1).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "role_code", "name", "data_scope", "is_system"}).
|
|
AddRow(uint64(7), "finance-role", nil, nil, "enabled", 1, "finance_operator", "Finance", "global", false))
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT platform_menu.* FROM "platform_menu" JOIN platform_role_menu_relation ON platform_role_menu_relation.platform_menu_id = platform_menu.id WHERE platform_role_menu_relation.platform_role_id = $1 AND platform_menu.status = $2 ORDER BY sort_no asc, id asc`)).
|
|
WithArgs(uint64(7), "enabled").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "parent_id", "menu_code", "name", "icon", "path", "sort_no"}).
|
|
AddRow(uint64(9), "finance-menu", nil, nil, "enabled", 1, uint64(0), "finance", "Finance", "", "/finance/payment", 1))
|
|
|
|
ctx, recorder := updateContext(http.MethodGet, "/platform/platform_menu", "", nil)
|
|
ctx.Set("Auth", &types.JwtClaims{Role: "finance_operator"})
|
|
ListPlatformMenu(ctx)
|
|
|
|
assertResponseCode(t, recorder, 0)
|
|
body := recorder.Body.String()
|
|
if !strings.Contains(body, `"identity":"finance-menu"`) || strings.Contains(body, `"gas-menu"`) {
|
|
t.Fatalf("non-root menu response was not constrained: %s", body)
|
|
}
|
|
assertMockExpectations(t, mock)
|
|
}
|
|
|
|
func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) {
|
|
response := resourceResponse(map[string]any{"identity": "item-a", "id": uint64(1), "ec_order_id": uint64(2), "ec_product_id": uint64(3), "items": []any{map[string]any{"identity": "child-a", "delivery_task_id": uint64(4)}}})
|
|
encoded, err := json.Marshal(response)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, forbidden := range []string{`"id"`, `"ec_order_id"`, `"ec_product_id"`, `"delivery_task_id"`} {
|
|
if strings.Contains(string(encoded), forbidden) {
|
|
t.Fatalf("response exposed internal relation key %s: %s", forbidden, encoded)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreatedResourceResponseUsesSafeAllowlist(t *testing.T) {
|
|
response := maskCreatedSensitiveFields(map[string]any{
|
|
"identity": "address-a",
|
|
"user_account_identity": "user-a",
|
|
"status": "draft",
|
|
"version": float64(1),
|
|
"phone": "13800138000",
|
|
"real_name": "张三",
|
|
"credential_no": "CERT-123456",
|
|
"address": "敏感详细地址",
|
|
"principal": "负责人",
|
|
"credit_code": "CREDIT-123",
|
|
"longitude": "120.123456",
|
|
"latitude": "30.456789",
|
|
})
|
|
encoded, err := json.Marshal(response)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := string(encoded)
|
|
if !strings.Contains(body, `"identity":"address-a"`) || !strings.Contains(body, `"user_account_identity":"user-a"`) || !strings.Contains(body, `"status":"draft"`) {
|
|
t.Fatalf("created response omitted safe public fields: %s", body)
|
|
}
|
|
for _, forbidden := range []string{`"phone"`, `"phone_masked"`, `"real_name"`, `"credential_no"`, `"address"`, `"principal"`, `"credit_code"`, `"longitude"`, `"latitude"`, "敏感详细地址", "负责人", "CREDIT-123", "120.123456", "30.456789"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("created response exposed sensitive field %s: %s", forbidden, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDefaultResourceResponseMasksPIIAndCoordinates(t *testing.T) {
|
|
ctx, _ := updateContext(http.MethodGet, "/user/account/user-a", "user-a", nil)
|
|
response := protectPreciseLocation(ctx, &models.UserAccount{}, map[string]any{
|
|
"identity": "user-a",
|
|
"name": "张三",
|
|
"phone": "13800138000",
|
|
"avatar": "https://private.example/avatar.png",
|
|
"address": "敏感详细地址",
|
|
"longitude": "120.123456",
|
|
"latitude": "30.456789",
|
|
})
|
|
encoded, err := json.Marshal(response)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := string(encoded)
|
|
if !strings.Contains(body, `"identity":"user-a"`) || !strings.Contains(body, `"phone_masked":"138****8000"`) || !strings.Contains(body, `"name_masked"`) {
|
|
t.Fatalf("default response omitted safe identity or masked PII: %s", body)
|
|
}
|
|
for _, forbidden := range []string{`"phone":`, `"name":`, `"avatar":`, `"address":`, `"longitude":"120.123456"`, `"latitude":"30.456789"`, "张三", "敏感详细地址", "private.example"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("default response leaked %s: %s", forbidden, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExplicitPreciseScopeRetainsCoordinatesButStillMasksPII(t *testing.T) {
|
|
ctx, _ := updateContext(http.MethodGet, "/user/address/address-a", "address-a", nil)
|
|
ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}})
|
|
response := protectPreciseLocation(ctx, &models.UserAddress{}, map[string]any{
|
|
"identity": "address-a",
|
|
"address": "敏感详细地址",
|
|
"longitude": "120.123456",
|
|
"latitude": "30.456789",
|
|
})
|
|
encoded, err := json.Marshal(response)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := string(encoded)
|
|
if !strings.Contains(body, `"longitude":"120.123456"`) || !strings.Contains(body, `"latitude":"30.456789"`) {
|
|
t.Fatalf("authorized response omitted precise coordinates: %s", body)
|
|
}
|
|
if strings.Contains(body, `"address":`) || strings.Contains(body, "敏感详细地址") {
|
|
t.Fatalf("precise location scope leaked address PII: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestPublicResponseProjectionRemovesCredentialAndAttachmentSecrets(t *testing.T) {
|
|
ctx, _ := updateContext(http.MethodGet, "/staff/credential/credential-a", "credential-a", nil)
|
|
ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}})
|
|
response := protectPreciseLocation(ctx, &models.StaffCredential{}, map[string]any{
|
|
"identity": "credential-a",
|
|
"credential_type": "installer",
|
|
"credential_no": "CERT-123456",
|
|
"evidence_uri": "private://evidence",
|
|
"file_uri": "private://file",
|
|
"attachment_uri": "private://attachment",
|
|
})
|
|
encoded, err := json.Marshal(response)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := string(encoded)
|
|
if !strings.Contains(body, `"identity":"credential-a"`) || !strings.Contains(body, `"credential_type":"installer"`) {
|
|
t.Fatalf("safe credential fields were removed: %s", body)
|
|
}
|
|
for _, forbidden := range []string{"credential_no", "evidence_uri", "file_uri", "attachment_uri", "CERT-123456", "private://"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("credential response leaked %s: %s", forbidden, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlatformAccountDetailMasksDisplayNameAndAvatarWithPreciseScope(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platfrom_account" WHERE identity = $1 ORDER BY "platfrom_account"."id" LIMIT $2`)).
|
|
WithArgs("account-a", 1).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "username", "display_name", "avatar", "password_hash", "platform_role_code", "phone"}).
|
|
AddRow(uint64(1), "account-a", nil, nil, "enabled", 1, "operator", "张三", "private://avatar", "hash", "finance_operator", "13800138000"))
|
|
|
|
ctx, recorder := updateContext(http.MethodGet, "/platform/platfrom_account/account-a", "account-a", nil)
|
|
ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}})
|
|
GetPlatfromAccount(ctx)
|
|
|
|
assertResponseCode(t, recorder, 0)
|
|
body := recorder.Body.String()
|
|
if !strings.Contains(body, `"display_name_masked"`) || !strings.Contains(body, `"phone_masked":"138****8000"`) {
|
|
t.Fatalf("platform account detail omitted masked PII: %s", body)
|
|
}
|
|
for _, forbidden := range []string{`"display_name":`, `"avatar":`, "张三", "private://avatar"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("platform account detail leaked %s: %s", forbidden, body)
|
|
}
|
|
}
|
|
assertMockExpectations(t, mock)
|
|
}
|
|
|
|
func TestPlatformAccountListMasksDisplayNameAndAvatar(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "platfrom_account"`)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platfrom_account" ORDER BY created_at desc LIMIT $1`)).
|
|
WithArgs(20).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "username", "display_name", "avatar", "password_hash", "platform_role_code", "phone"}).
|
|
AddRow(uint64(1), "account-a", nil, nil, "enabled", 1, "operator", "张三", "private://avatar", "hash", "finance_operator", "13800138000"))
|
|
|
|
ctx, recorder := updateContext(http.MethodGet, "/platform/platfrom_account", "", nil)
|
|
ListPlatfromAccount(ctx)
|
|
|
|
assertResponseCode(t, recorder, 0)
|
|
body := recorder.Body.String()
|
|
if !strings.Contains(body, `"display_name_masked"`) || strings.Contains(body, `"display_name":`) || strings.Contains(body, `"avatar":`) {
|
|
t.Fatalf("platform account list did not apply the masked projection: %s", body)
|
|
}
|
|
assertMockExpectations(t, mock)
|
|
}
|
|
|
|
func TestNonRootCannotManagePlatformRolesOrMenus(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
handler gin.HandlerFunc
|
|
method string
|
|
target string
|
|
identity string
|
|
body string
|
|
}{
|
|
{"create role", CreatePlatformRole, http.MethodPost, "/platform/platform_role", "", `{"role_code":"auditor","name":"Auditor"}`},
|
|
{"create menu", CreatePlatformMenu, http.MethodPost, "/platform/platform_menu", "", `{"menu_code":"audit","name":"Audit","path":"/audit"}`},
|
|
{"update menu status", UpdatePlatformMenuStatus, http.MethodPatch, "/platform/platform_menu/menu-a/status", "menu-a", `{"status":"disabled"}`},
|
|
{"archive menu", ArchivePlatformMenu, http.MethodDelete, "/platform/platform_menu/menu-a", "menu-a", ``},
|
|
{"replace role menus", ReplacePlatformRoleMenus, http.MethodPut, "/platform/platform_role/role-a/menu", "role-a", `{"menu_identities":[]}`},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
ctx, recorder := updateContext(test.method, test.target, test.identity, []byte(test.body))
|
|
ctx.Set("Auth", &types.JwtClaims{Role: "platform_operator"})
|
|
|
|
test.handler(ctx)
|
|
|
|
assertResponseCode(t, recorder, int32(status.Code(errcode.ErrPermissionDenied)))
|
|
assertMockExpectations(t, mock)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlatformMenuAllowsOnlyAssignedDomain(t *testing.T) {
|
|
menus := []models.PlatformMenu{
|
|
{MenuCode: "finance", Path: "/finance"},
|
|
{MenuCode: "fin_payment", Path: "/finance/fin-payment"},
|
|
}
|
|
if !platformMenuAllowsPath(menus, "/heqi/platform/v1/finance/fin_payment") {
|
|
t.Fatal("assigned finance domain should be allowed")
|
|
}
|
|
if platformMenuAllowsPath(menus, "/heqi/platform/v1/user/account") {
|
|
t.Fatal("unassigned user domain should be denied")
|
|
}
|
|
}
|
|
|
|
func TestCreatePlatformAccountRequiresAssignableNonRootRole(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{"missing role", `{"username":"operator","password":"secure-password"}`},
|
|
{"root role", `{"username":"operator","password":"secure-password","platform_role_code":"root"}`},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
ctx, recorder := updateContext(http.MethodPost, "/platform/platfrom_account", "", []byte(test.body))
|
|
ctx.Set("Auth", &types.JwtClaims{Role: "root"})
|
|
|
|
CreatePlatfromAccount(ctx)
|
|
|
|
assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument)))
|
|
assertMockExpectations(t, mock)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNonRootCannotAssignPlatformAccountRole(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
handler gin.HandlerFunc
|
|
method string
|
|
identity string
|
|
body string
|
|
}{
|
|
{"create account", CreatePlatfromAccount, http.MethodPost, "", `{"username":"operator","password":"secure-password","platform_role_code":"auditor"}`},
|
|
{"change account role", UpdatePlatfromAccount, http.MethodPut, "account-a", `{"platform_role_code":"auditor"}`},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
ctx, recorder := updateContext(test.method, "/platform/platfrom_account/"+test.identity, test.identity, []byte(test.body))
|
|
ctx.Set("Auth", &types.JwtClaims{Role: "platform_operator"})
|
|
|
|
test.handler(ctx)
|
|
|
|
assertResponseCode(t, recorder, int32(status.Code(errcode.ErrPermissionDenied)))
|
|
assertMockExpectations(t, mock)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListSafetyEventDisposalsReturnsOnlyTheRequestedEventHistory(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "saf_event_disposal" WHERE saf_event_identity = $1`)).
|
|
WithArgs("event-a").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "saf_event_disposal" WHERE saf_event_identity = $1 ORDER BY created_at asc LIMIT $2`)).
|
|
WithArgs("event-a", 20).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "saf_event_identity", "action", "reason", "operator_identity"}).
|
|
AddRow(uint64(9), "disposal-a", nil, nil, "enabled", 1, "event-a", "close", "resolved", "operator-a"))
|
|
|
|
ctx, recorder := updateContext(http.MethodGet, "/safety/saf_event/event-a/disposals", "event-a", nil)
|
|
ListSafetyEventDisposals(ctx)
|
|
|
|
assertResponseCode(t, recorder, 0)
|
|
if !strings.Contains(recorder.Body.String(), `"saf_event_identity":"event-a"`) || strings.Contains(recorder.Body.String(), `"id":`) {
|
|
t.Fatalf("disposal history did not keep the event identity-only shape: %s", recorder.Body.String())
|
|
}
|
|
assertMockExpectations(t, mock)
|
|
}
|
|
|
|
func TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID(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)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "gas-a"))
|
|
|
|
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_id"`) || strings.Contains(body, `"id":`) {
|
|
t.Fatalf("account list did not return the public relation shape: %s", body)
|
|
}
|
|
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()
|
|
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"))
|
|
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)
|
|
|
|
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 TestListDeliveryTrackPointsMasksCoordinatesWithoutPreciseLocationScope(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
now := time.Now().UTC()
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "delivery_track_point"`)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" ORDER BY created_at desc LIMIT $1`)).
|
|
WithArgs(20).
|
|
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", now, now, "enabled", 1, uint64(7), "arrival", now, "120.123456", "30.456789"))
|
|
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_point", "", nil)
|
|
listResource(ctx, &models.DeliveryTrackPoint{})
|
|
|
|
assertResponseCode(t, recorder, 0)
|
|
body := recorder.Body.String()
|
|
if strings.Contains(body, "120.123456") || strings.Contains(body, "30.456789") {
|
|
t.Fatalf("track-point list exposed precise coordinates without scope: %s", body)
|
|
}
|
|
if !strings.Contains(body, `"delivery_track_identity":"track-a"`) {
|
|
t.Fatalf("track-point list omitted its public relation identity: %s", body)
|
|
}
|
|
assertMockExpectations(t, mock)
|
|
}
|
|
|
|
func TestGetDeliveryTrackPointReturnsCoordinatesWithPreciseLocationScope(t *testing.T) {
|
|
_, mock := setupPlatformRoleDatabase(t)
|
|
now := time.Now().UTC()
|
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" WHERE identity = $1 ORDER BY "delivery_track_point"."id" LIMIT $2`)).
|
|
WithArgs("point-a", 1).
|
|
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", now, now, "enabled", 1, uint64(7), "arrival", now, "120.123456", "30.456789"))
|
|
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_point/point-a", "point-a", nil)
|
|
ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}})
|
|
getResource(ctx, &models.DeliveryTrackPoint{})
|
|
|
|
assertResponseCode(t, recorder, 0)
|
|
if !strings.Contains(recorder.Body.String(), "120.123456") || !strings.Contains(recorder.Body.String(), "30.456789") {
|
|
t.Fatalf("authorized track-point detail omitted 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":[]}`))
|
|
ctx.Set("Auth", &types.JwtClaims{Role: "root"})
|
|
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":[]}`))
|
|
ctx.Set("Auth", &types.JwtClaims{Role: "root"})
|
|
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)
|
|
}
|