fix(backend): sync user tunnel status and relax forward speedId permission check

- Return actual user_tunnel.status in admin permission list instead of hardcoded 1
- Allow non-admin users to update forwards when keeping the same speedId selection
- Add contract tests for user tunnel status mapping and forward permission edge case

Entire-Checkpoint: deb90fb942ee
This commit is contained in:
sagitchu
2026-03-08 00:56:13 +08:00
parent addf83a249
commit 3e11549370
7 changed files with 158 additions and 8 deletions
+1 -1
View File
@@ -571,7 +571,7 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
"userId": t.UserID, "userId": t.UserID,
"tunnelId": t.TunnelID, "tunnelId": t.TunnelID,
"tunnelName": t.TunnelName, "tunnelName": t.TunnelName,
"status": 1, "status": t.Status,
"flow": t.Flow, "flow": t.Flow,
"num": t.Num, "num": t.Num,
"expTime": t.ExpTime, "expTime": t.ExpTime,
+14 -6
View File
@@ -1279,13 +1279,13 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
if strategy == "" { if strategy == "" {
strategy = forward.Strategy strategy = forward.Strategy
} }
if actorRole != 0 { rawSpeedID, hasSpeedID := req["speedId"]
if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil { requestedSpeedID := asAnyToInt64Ptr(rawSpeedID)
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则")) if actorRole != 0 && hasSpeedID && requestedSpeedID != nil && !sameSpeedLimitSelection(forward.SpeedID, requestedSpeedID) {
return response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
} return
} }
speedID := asAnyToInt64Ptr(req["speedId"]) speedID := requestedSpeedID
speedID, err = h.normalizeSpeedLimitReference(speedID) speedID, err = h.normalizeSpeedLimitReference(speedID)
if err != nil { if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -3380,6 +3380,14 @@ func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
return speedID, nil return speedID, nil
} }
func sameSpeedLimitSelection(current sql.NullInt64, requested *int64) bool {
if requested == nil {
return !current.Valid
}
return current.Valid && current.Int64 == *requested
}
func asAnySlice(v interface{}) []interface{} { func asAnySlice(v interface{}) []interface{} {
if v == nil { if v == nil {
return nil return nil
+1
View File
@@ -573,6 +573,7 @@ type UserTunnelDetail struct {
UserID int64 UserID int64
TunnelID int64 TunnelID int64
TunnelName string TunnelName string
Status int
TunnelFlow int TunnelFlow int
Flow int64 Flow int64
InFlow int64 InFlow int64
+1 -1
View File
@@ -447,7 +447,7 @@ func (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDeta
} }
var items []model.UserTunnelDetail var items []model.UserTunnelDetail
err := r.db.Model(&model.UserTunnel{}). err := r.db.Model(&model.UserTunnel{}).
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed"). Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
Joins("LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id"). Joins("LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id").
Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id"). Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id").
Where("user_tunnel.user_id = ?", userID). Where("user_tunnel.user_id = ?", userID).
@@ -1310,6 +1310,30 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
assertCode(t, res, 0) assertCode(t, res, 0)
}) })
t.Run("non-admin can update when request keeps existing speedId", func(t *testing.T) {
if err := repo.DB().Exec(`UPDATE forward SET speed_id = ? WHERE id = ?`, speedID, forwardID).Error; err != nil {
t.Fatalf("assign forward speed limit: %v", err)
}
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-keep-speed",
"tunnelId": tunnelID,
"remoteAddr": "9.10.11.12:443",
"speedId": speedID,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("non-admin can create with speedId null and inPort 0", func(t *testing.T) { t.Run("non-admin can create with speedId null and inPort 0", func(t *testing.T) {
createPayload := map[string]interface{}{ createPayload := map[string]interface{}{
"name": "perm-forward-null-values", "name": "perm-forward-null-values",
@@ -0,0 +1,105 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestUserTunnelListReturnsStoredStatusContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(201, 'user_tunnel_status_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(301, 'user-tunnel-status-enabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel enabled: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(302, 'user-tunnel-status-disabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel disabled: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(401, 201, 301, NULL, 10, 500, 0, 0, 1, 2727251700000, 1)
`).Error; err != nil {
t.Fatalf("insert enabled user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(402, 201, 302, NULL, 10, 500, 0, 0, 1, 2727251700000, 0)
`).Error; err != nil {
t.Fatalf("insert disabled user_tunnel: %v", err)
}
body := bytes.NewBufferString(`{"userId":201}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/list", body)
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
}
items, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("expected array data, got %T", out.Data)
}
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
statusByTunnelID := make(map[int64]int, len(items))
for _, item := range items {
obj, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("expected object item, got %T", item)
}
tunnelID, ok := obj["tunnelId"].(float64)
if !ok {
t.Fatalf("expected tunnelId to be float64, got %T", obj["tunnelId"])
}
status, ok := obj["status"].(float64)
if !ok {
t.Fatalf("expected status to be float64, got %T", obj["status"])
}
statusByTunnelID[int64(tunnelID)] = int(status)
}
if statusByTunnelID[301] != 1 {
t.Fatalf("expected enabled tunnel status 1, got %d", statusByTunnelID[301])
}
if statusByTunnelID[302] != 0 {
t.Fatalf("expected disabled tunnel status 0, got %d", statusByTunnelID[302])
}
}
@@ -0,0 +1,12 @@
# 018 User Tunnel Disable Status Sync
## Checklist
- [x] Inspect the user tunnel permission edit flow and identify why disabling an assigned tunnel appears ineffective.
- [x] Return the real `user_tunnel.status` value from the admin permission list API instead of a hardcoded enabled state.
- [x] Add contract coverage for the user tunnel permission list status mapping and run focused backend verification.
## Test Record
- Command: `cd go-backend && go test ./tests/contract/...`
- Result: passed.