mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
feat: isolate manual verification RPC usage
- add rpc node purpose support for general/manual_verify/both - keep scan, WSS and health checks away from manual_verify nodes - fallback manual hash verification from general RPC to manual_verify RPC - add public cashier hash submission path and errno responses - update tests for RPC selection, failover and manual payment flows
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
@@ -339,8 +340,15 @@ func TestAdminInitPasswordFlow(t *testing.T) {
|
||||
if recFetch2.Code != http.StatusBadRequest {
|
||||
t.Fatalf("second fetch should fail with 400, got %d body=%s", recFetch2.Code, recFetch2.Body.String())
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(recFetch2.Body.String()), "already fetched") {
|
||||
t.Fatalf("expected already fetched error, got: %s", recFetch2.Body.String())
|
||||
var respFetch2 map[string]interface{}
|
||||
if err := json.Unmarshal(recFetch2.Body.Bytes(), &respFetch2); err != nil {
|
||||
t.Fatalf("unmarshal second fetch response: %v", err)
|
||||
}
|
||||
if got := int(respFetch2["status_code"].(float64)); got != 10040 {
|
||||
t.Fatalf("second fetch status_code = %d, want 10040; response=%v", got, respFetch2)
|
||||
}
|
||||
if got, _ := respFetch2["message"].(string); got != constant.Errno[10040] {
|
||||
t.Fatalf("second fetch message = %q, want %q; response=%v", got, constant.Errno[10040], respFetch2)
|
||||
}
|
||||
|
||||
token := adminLogin(t, e, testAdminUsername, initPassword)
|
||||
@@ -523,14 +531,25 @@ func TestAdminRpcNodes_CRUD(t *testing.T) {
|
||||
if nodeID == nil {
|
||||
t.Fatal("CreateRpcNode missing id")
|
||||
}
|
||||
if got, _ := dataObj["purpose"].(string); got != mdb.RpcNodePurposeGeneral {
|
||||
t.Fatalf("created purpose = %q, want %q", got, mdb.RpcNodePurposeGeneral)
|
||||
}
|
||||
nodeIDStr := fmt.Sprintf("%.0f", nodeID.(float64))
|
||||
|
||||
// Update.
|
||||
rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{
|
||||
"url": "https://eth-mainnet2.example.com",
|
||||
"url": "https://eth-mainnet2.example.com",
|
||||
"purpose": mdb.RpcNodePurposeManualVerify,
|
||||
}, token)
|
||||
t.Logf("UpdateRpcNode: %s", rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
updatedNode, err := data.GetRpcNodeByID(uint64(nodeID.(float64)))
|
||||
if err != nil {
|
||||
t.Fatalf("reload rpc node: %v", err)
|
||||
}
|
||||
if updatedNode.Purpose != mdb.RpcNodePurposeManualVerify {
|
||||
t.Fatalf("updated purpose = %q, want %q", updatedNode.Purpose, mdb.RpcNodePurposeManualVerify)
|
||||
}
|
||||
|
||||
// Health check — network likely unreachable in test, but route must not 404/500.
|
||||
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr+"/health-check", nil, token)
|
||||
@@ -545,6 +564,95 @@ func TestAdminRpcNodes_CRUD(t *testing.T) {
|
||||
assertOK(t, rec)
|
||||
}
|
||||
|
||||
func TestAdminRpcNodes_RejectsURLTypeMismatch(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
||||
"network": "ethereum",
|
||||
"url": "wss://eth-mainnet.example.com",
|
||||
"type": mdb.RpcNodeTypeHttp,
|
||||
}, token)
|
||||
t.Logf("CreateRpcNode(http with wss): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected create mismatch to fail, got 200: %s", rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec, 10022)
|
||||
|
||||
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
||||
"network": "ethereum",
|
||||
"url": "https://eth-mainnet.example.com",
|
||||
"type": mdb.RpcNodeTypeWs,
|
||||
}, token)
|
||||
t.Logf("CreateRpcNode(ws with https): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected create mismatch to fail, got 200: %s", rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec, 10023)
|
||||
|
||||
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
||||
"network": "ethereum",
|
||||
"url": "https://eth-mainnet.example.com",
|
||||
"type": mdb.RpcNodeTypeHttp,
|
||||
"purpose": "invalid-purpose",
|
||||
}, token)
|
||||
t.Logf("CreateRpcNode(invalid purpose): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected invalid purpose to fail, got 200: %s", rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec, 10020)
|
||||
|
||||
rec = doPostAdmin(e, "/admin/api/v1/rpc-nodes", map[string]interface{}{
|
||||
"network": "ethereum",
|
||||
"url": "https://eth-mainnet.example.com",
|
||||
"type": mdb.RpcNodeTypeHttp,
|
||||
}, token)
|
||||
resp := assertOK(t, rec)
|
||||
dataObj, _ := resp["data"].(map[string]interface{})
|
||||
nodeID := uint64(dataObj["id"].(float64))
|
||||
nodeIDStr := fmt.Sprintf("%d", nodeID)
|
||||
|
||||
rec = doPatchAdmin(e, "/admin/api/v1/rpc-nodes/"+nodeIDStr, map[string]interface{}{
|
||||
"url": "wss://eth-mainnet.example.com",
|
||||
}, token)
|
||||
t.Logf("UpdateRpcNode(http with wss): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected update mismatch to fail, got 200: %s", rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec, 10022)
|
||||
|
||||
node, err := data.GetRpcNodeByID(nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload rpc node: %v", err)
|
||||
}
|
||||
if node.Url != "https://eth-mainnet.example.com" {
|
||||
t.Fatalf("node url changed after rejected update: %q", node.Url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPathIDParseErrorUsesParamsErrno(t *testing.T) {
|
||||
e, token := setupAdminTestEnv(t)
|
||||
|
||||
rec := doGetAdmin(e, "/admin/api/v1/api-keys/not-a-number/stats", token)
|
||||
assertErrorCode(t, rec, 10009)
|
||||
}
|
||||
|
||||
func assertErrorCode(t *testing.T, rec *httptest.ResponseRecorder, wantCode int) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected HTTP 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal error response: %v", err)
|
||||
}
|
||||
if got := int(resp["status_code"].(float64)); got != wantCode {
|
||||
t.Fatalf("status_code = %d, want %d; response=%v", got, wantCode, resp)
|
||||
}
|
||||
if got, _ := resp["message"].(string); got != constant.Errno[wantCode] {
|
||||
t.Fatalf("message = %q, want %q; response=%v", got, constant.Errno[wantCode], resp)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wallets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminWallets_CRUD verifies wallet management routes.
|
||||
@@ -693,9 +801,14 @@ func TestAdminOrders_MarkPaidSuccessAfterVerification(t *testing.T) {
|
||||
})
|
||||
defer restore()
|
||||
|
||||
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
|
||||
jsonBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"block_transaction_id": "block-admin-ok",
|
||||
}, token)
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", strings.NewReader(string(jsonBytes)))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("MarkOrderPaid success: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
assertOK(t, rec)
|
||||
if !verified {
|
||||
@@ -1211,8 +1324,11 @@ func TestAdminSettings_RejectsPrivateRateAPIURL(t *testing.T) {
|
||||
if result["ok"] != false {
|
||||
t.Fatalf("private rate.api_url result = %v, want ok=false", result)
|
||||
}
|
||||
if got, _ := result["error"].(string); !strings.Contains(got, "rate.api_url invalid") {
|
||||
t.Fatalf("private rate.api_url error = %q", got)
|
||||
if got := int(result["error_code"].(float64)); got != 10043 {
|
||||
t.Fatalf("private rate.api_url error_code = %d, want 10043; result=%v", got, result)
|
||||
}
|
||||
if got, _ := result["error"].(string); got != constant.Errno[10043] {
|
||||
t.Fatalf("private rate.api_url error = %q, want %q", got, constant.Errno[10043])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -75,7 +75,7 @@ func RegisterRoute(e *echo.Echo) {
|
||||
|
||||
formParams, err := ctx.FormParams()
|
||||
if err != nil && ctx.Request().Method == http.MethodPost {
|
||||
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid epay form params: %w", err))
|
||||
return comm.Ctrl.FailJson(ctx, constant.ParamsMarshalErr)
|
||||
}
|
||||
if err == nil {
|
||||
copyParams(formParams)
|
||||
@@ -132,7 +132,7 @@ func RegisterRoute(e *echo.Echo) {
|
||||
|
||||
amountFloat, err := strconv.ParseFloat(money, 64)
|
||||
if err != nil {
|
||||
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid money value: %s", money))
|
||||
return comm.Ctrl.FailJson(ctx, constant.PayAmountErr)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
@@ -154,7 +154,7 @@ func RegisterRoute(e *echo.Echo) {
|
||||
|
||||
jsonBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return comm.Ctrl.FailJson(ctx, err)
|
||||
return comm.Ctrl.FailJson(ctx, constant.SystemErr)
|
||||
}
|
||||
|
||||
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
|
||||
|
||||
+160
-3
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/sign"
|
||||
@@ -286,6 +287,39 @@ func TestCreateOrderGmpayRejectsPrivateNotifyURL(t *testing.T) {
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal private notify response: %v", err)
|
||||
}
|
||||
if got := int(resp["status_code"].(float64)); got != 10041 {
|
||||
t.Fatalf("status_code = %d, want 10041; response=%v", got, resp)
|
||||
}
|
||||
if got, _ := resp["message"].(string); got != constant.Errno[10041] {
|
||||
t.Fatalf("message = %q, want %q", got, constant.Errno[10041])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicJSONBindErrorUsesParamsErrno(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/pay/submit-tx-hash/bad-json-order", strings.NewReader("{"))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal bind error response: %v", err)
|
||||
}
|
||||
if got := int(resp["status_code"].(float64)); got != 10009 {
|
||||
t.Fatalf("status_code = %d, want 10009; response=%v", got, resp)
|
||||
}
|
||||
if got, _ := resp["message"].(string); got != constant.Errno[10009] {
|
||||
t.Fatalf("message = %q, want %q", got, constant.Errno[10009])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateOrderGmpayV1SolNative tests creating an order for native SOL token.
|
||||
@@ -1101,9 +1135,13 @@ func TestSubmitTxHash_SuccessUpdatesCheckStatus(t *testing.T) {
|
||||
})
|
||||
defer restore()
|
||||
|
||||
rec := doPost(e, "/pay/submit-tx-hash/"+tradeID, map[string]interface{}{
|
||||
jsonBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"block_transaction_id": " user-submitted-hash ",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/pay/submit-tx-hash/"+tradeID, strings.NewReader(string(jsonBytes)))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
t.Logf("SubmitTxHash(success): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
@@ -1127,9 +1165,9 @@ func TestSubmitTxHash_SuccessUpdatesCheckStatus(t *testing.T) {
|
||||
t.Fatalf("submit response status = %d, want %d", got, mdb.StatusPaySuccess)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
|
||||
statusReq := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
|
||||
statusRec := httptest.NewRecorder()
|
||||
e.ServeHTTP(statusRec, req)
|
||||
e.ServeHTTP(statusRec, statusReq)
|
||||
if statusRec.Code != http.StatusOK {
|
||||
t.Fatalf("check-status expected 200, got %d: %s", statusRec.Code, statusRec.Body.String())
|
||||
}
|
||||
@@ -1146,6 +1184,86 @@ func TestSubmitTxHash_SuccessUpdatesCheckStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitTxHash_VerificationFailureAllowsRetrySameHash(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
tradeID := createCheckoutCounterRespTestOrder(t, e, "submit-tx-hash-retry-001")
|
||||
|
||||
calls := 0
|
||||
restore := service.SetManualOrderPaymentValidatorForTest(func(order *mdb.Orders, blockID string) (string, error) {
|
||||
calls++
|
||||
if order.TradeId != tradeID {
|
||||
t.Fatalf("validator trade_id = %s, want %s", order.TradeId, tradeID)
|
||||
}
|
||||
if blockID != "retry-hash" {
|
||||
t.Fatalf("validator block id = %s, want retry-hash", blockID)
|
||||
}
|
||||
if calls == 1 {
|
||||
return "", fmt.Errorf("rpc verification failed")
|
||||
}
|
||||
return "canonical-retry-hash", nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
rec := doPost(e, "/pay/submit-tx-hash/"+tradeID, map[string]interface{}{
|
||||
"block_transaction_id": "retry-hash",
|
||||
})
|
||||
t.Logf("SubmitTxHash(verification failure): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var failResp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &failResp); err != nil {
|
||||
t.Fatalf("unmarshal failure response: %v", err)
|
||||
}
|
||||
if got := int(failResp["status_code"].(float64)); got != 10038 {
|
||||
t.Fatalf("failure status_code = %d, want 10038", got)
|
||||
}
|
||||
if got, _ := failResp["message"].(string); got != constant.Errno[10038] {
|
||||
t.Fatalf("failure message = %q, want %q", got, constant.Errno[10038])
|
||||
}
|
||||
|
||||
reloaded, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order after failure: %v", err)
|
||||
}
|
||||
if reloaded.Status != mdb.StatusWaitPay || reloaded.BlockTransactionId != "" {
|
||||
t.Fatalf("order changed after failed submit: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
|
||||
}
|
||||
|
||||
statusReq := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
|
||||
statusRec := httptest.NewRecorder()
|
||||
e.ServeHTTP(statusRec, statusReq)
|
||||
if statusRec.Code != http.StatusOK {
|
||||
t.Fatalf("check-status expected 200, got %d: %s", statusRec.Code, statusRec.Body.String())
|
||||
}
|
||||
var statusResp map[string]interface{}
|
||||
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusResp); err != nil {
|
||||
t.Fatalf("unmarshal check-status response: %v", err)
|
||||
}
|
||||
statusData := statusResp["data"].(map[string]interface{})
|
||||
if got := int(statusData["status"].(float64)); got != mdb.StatusWaitPay {
|
||||
t.Fatalf("check-status status after failed submit = %d, want %d", got, mdb.StatusWaitPay)
|
||||
}
|
||||
|
||||
retryRec := doPost(e, "/pay/submit-tx-hash/"+tradeID, map[string]interface{}{
|
||||
"block_transaction_id": "retry-hash",
|
||||
})
|
||||
t.Logf("SubmitTxHash(retry same hash): status=%d body=%s", retryRec.Code, retryRec.Body.String())
|
||||
if retryRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected retry success, got %d: %s", retryRec.Code, retryRec.Body.String())
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("validator calls = %d, want 2", calls)
|
||||
}
|
||||
reloaded, err = data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload order after retry: %v", err)
|
||||
}
|
||||
if reloaded.Status != mdb.StatusPaySuccess || reloaded.BlockTransactionId != "canonical-retry-hash" {
|
||||
t.Fatalf("order after retry: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitTxHash_RejectsOkPayOrder(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
order := &mdb.Orders{
|
||||
@@ -1192,6 +1310,45 @@ func TestSubmitTxHash_RejectsOkPayOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitTxHash_RejectsExistingHashBeforeRpc(t *testing.T) {
|
||||
e := setupTestEnv(t)
|
||||
existing := &mdb.Orders{
|
||||
TradeId: "trade-submit-tx-hash-existing",
|
||||
OrderId: "order-submit-tx-hash-existing",
|
||||
BlockTransactionId: "existing-hash",
|
||||
Amount: 10,
|
||||
Currency: "CNY",
|
||||
ActualAmount: 1.23,
|
||||
ReceiveAddress: "TTestTronAddress001",
|
||||
Token: "USDT",
|
||||
Network: mdb.NetworkTron,
|
||||
Status: mdb.StatusPaySuccess,
|
||||
PayProvider: mdb.PaymentProviderOnChain,
|
||||
}
|
||||
if err := dao.Mdb.Create(existing).Error; err != nil {
|
||||
t.Fatalf("create existing paid order: %v", err)
|
||||
}
|
||||
tradeID := createCheckoutCounterRespTestOrder(t, e, "submit-tx-hash-existing-001")
|
||||
|
||||
called := false
|
||||
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
|
||||
called = true
|
||||
return "existing-hash", nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
rec := doPost(e, "/pay/submit-tx-hash/"+tradeID, map[string]interface{}{
|
||||
"block_transaction_id": "existing-hash",
|
||||
})
|
||||
t.Logf("SubmitTxHash(existing hash): status=%d body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
||||
}
|
||||
if called {
|
||||
t.Fatal("verifier should not run when hash already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckoutCounter_NotFound verifies that /pay/checkout-counter/:trade_id
|
||||
// with an unknown trade_id does not return an empty 404 (which would mean the
|
||||
// route is not registered). When the static HTML template is present the
|
||||
|
||||
Reference in New Issue
Block a user