Merge owner-name correction for issue #574

This commit is contained in:
sagitchu
2026-11-10 08:18:52 +08:00
3 changed files with 311 additions and 4 deletions
+2 -1
View File
@@ -1001,7 +1001,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
var rows []fwdRow
err := r.db.Model(&model.Forward{}).
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol, forward.proxy_protocol_receive, forward.proxy_protocol_send").
Select("forward.id, forward.user_id, COALESCE(owner.\"user\", forward.user_name) AS user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol, forward.proxy_protocol_receive, forward.proxy_protocol_send").
Joins(`LEFT JOIN "user" AS owner ON owner.id = forward.user_id`).
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
Joins("LEFT JOIN speed_limit AS ip_speed_limit ON ip_speed_limit.id = forward.ip_speed_id").
Order("forward.inx ASC, forward.id ASC").
@@ -37,8 +37,14 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
if tx == nil {
return nil, errors.New("database unavailable")
}
var forwards []model.Forward
err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
var forwards []struct {
model.Forward
CurrentUserName string
}
err := tx.Model(&model.Forward{}).
Select(`forward.*, COALESCE(owner."user", forward.user_name) AS current_user_name`).
Joins(`LEFT JOIN "user" AS owner ON owner.id = forward.user_id`).
Where("forward.tunnel_id = ?", tunnelID).Order("forward.id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
@@ -48,7 +54,7 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
rows = append(rows, model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
UserName: f.CurrentUserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
@@ -0,0 +1,300 @@
package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"go-backend/internal/auth"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/security"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
)
func TestForwardOwnerNamesFollowAccountRenames(t *testing.T) {
for _, database := range []string{"sqlite", "postgres"} {
t.Run(database, func(t *testing.T) {
for _, mode := range []string{"admin-name-only", "admin-name-and-password", "self-name-and-password"} {
t.Run(mode, func(t *testing.T) {
const secret = "issue574-isolated-test"
open := ownerNamesRepository(t, database)
r := open()
router := httpserver.NewRouter(handler.New(r, secret), secret)
for _, u := range []model.User{
{ID: 9103, User: "before-name", Pwd: security.MD5("old-password"), RoleID: 1, Status: 1, PasswordChangedAt: time.Now().Add(-10 * time.Second).UnixMilli()},
{ID: 9104, User: "other-user", Pwd: security.MD5("other-password"), RoleID: 1, Status: 1, PasswordChangedAt: time.Now().Add(-10 * time.Second).UnixMilli()},
} {
if err := r.DB().Create(&u).Error; err != nil {
t.Fatal(err)
}
}
exp := time.Now().Add(time.Hour).UnixMilli()
f := model.Forward{ID: 9301, UserID: 9103, UserName: "before-name", Name: "existing-rule", TunnelID: 9201, RemoteAddr: "127.0.0.1:8080", Status: 1, InFlow: 123, OutFlow: 456}
for _, v := range []interface{}{&model.Tunnel{ID: 9201, Name: "isolated", Type: 1, Protocol: "tls", Status: 1, Flow: 1}, &model.UserTunnel{ID: 9401, UserID: 9103, TunnelID: 9201, Status: 1, Flow: 100, Num: 10, ExpTime: exp}, &f} {
if err := r.DB().Create(v).Error; err != nil {
t.Fatal(err)
}
}
token := func(id int64, name string, role int) string {
v, e := auth.GenerateTokenAt(id, name, role, secret, time.Now().Add(time.Second))
if e != nil {
t.Fatal(e)
}
return v
}
admin := token(1, "admin_user", 0)
oldToken, e := auth.GenerateTokenAt(9103, "before-name", 1, secret, time.Now().Add(-time.Second))
if e != nil {
t.Fatal(e)
}
call := func(path, tok string, body interface{}) (int, json.RawMessage) {
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
q := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
q.Header.Set("Authorization", tok)
q.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, q)
var out struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
Msg string `json:"msg"`
}
if e := json.Unmarshal(w.Body.Bytes(), &out); e != nil {
t.Fatal(e)
}
return out.Code, out.Data
}
path, tok := "/api/v1/user/update", admin
body := map[string]interface{}{"id": 9103, "user": "after-name", "flow": 100, "num": 10, "expTime": exp, "flowResetTime": 1, "status": 1, "maxConn": 0}
if mode == "admin-name-and-password" {
body["pwd"] = "new-password"
}
if mode == "self-name-and-password" {
path = "/api/v1/user/updatePassword"
tok = oldToken
body = map[string]interface{}{"newUsername": "after-name", "currentPassword": "old-password", "newPassword": "new-password", "confirmPassword": "new-password"}
}
if code, _ := call(path, tok, body); code != 0 {
t.Fatalf("rename code=%d", code)
}
fresh := token(9103, "after-name", 1)
code, data := call("/api/v1/forward/list", admin, map[string]interface{}{})
if code != 0 {
t.Fatal(code)
}
var rows []map[string]interface{}
if err := json.Unmarshal(data, &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0]["userName"] != "after-name" || rows[0]["userId"] != float64(9103) {
t.Fatalf("expected current owner name: %s", data)
}
if rows[0]["id"] != float64(9301) || rows[0]["inFlow"] != float64(123) || rows[0]["outFlow"] != float64(456) || rows[0]["status"] != float64(1) {
t.Fatalf("rule identity/traffic/status changed: %s", data)
}
if code, data = call("/api/v1/forward/list", fresh, map[string]interface{}{}); code != 0 {
t.Fatalf("owner denied: %d", code)
}
rows = nil
if err := json.Unmarshal(data, &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0]["userName"] != "after-name" {
t.Fatalf("owner cannot see renamed rule: %s", data)
}
code, data = call("/api/v1/tunnel/delete-preview", admin, map[string]interface{}{"id": 9201})
if code != 0 {
t.Fatalf("preview code=%d", code)
}
var preview struct {
SampleForwards []struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
UserName string `json:"userName"`
} `json:"sampleForwards"`
}
if err := json.Unmarshal(data, &preview); err != nil {
t.Fatal(err)
}
if len(preview.SampleForwards) != 1 || preview.SampleForwards[0].ID != 9301 || preview.SampleForwards[0].UserID != 9103 || preview.SampleForwards[0].UserName != "after-name" {
t.Fatalf("preview must show current owner: %s", data)
}
code, data = call("/api/v1/tunnel/batch-delete-preview", admin, map[string]interface{}{"ids": []int64{9201}})
if code != 0 {
t.Fatalf("batch preview code=%d", code)
}
var batch struct {
Items []struct {
SampleForwards []struct {
UserName string `json:"userName"`
} `json:"sampleForwards"`
} `json:"items"`
}
if err := json.Unmarshal(data, &batch); err != nil {
t.Fatal(err)
}
if len(batch.Items) != 1 || len(batch.Items[0].SampleForwards) != 1 || batch.Items[0].SampleForwards[0].UserName != "after-name" {
t.Fatalf("batch preview must show current owner: %s", data)
}
exported, err := r.ExportAll()
if err != nil {
t.Fatal(err)
}
if len(exported.UserTunnels) != 1 || exported.UserTunnels[0].UserID != 9103 || exported.UserTunnels[0].TunnelID != 9201 || exported.UserTunnels[0].Flow != 100 || exported.UserTunnels[0].Status != 1 {
t.Fatalf("rename changed tunnel grant: %+v", exported.UserTunnels)
}
oldCode, _ := call("/api/v1/forward/list", oldToken, map[string]interface{}{})
wantOld := 0
if mode != "admin-name-only" {
wantOld = 401
}
if oldCode != wantOld {
t.Fatalf("old token=%d want %d", oldCode, wantOld)
}
// Re-use the abandoned username for another identity; it must not acquire rules.
if code, _ := call("/api/v1/user/update", admin, map[string]interface{}{"id": 9104, "user": "before-name", "flow": 100, "num": 10, "expTime": exp, "flowResetTime": 1, "status": 1, "maxConn": 0}); code != 0 {
t.Fatalf("reuse old name: %d", code)
}
other := token(9104, "before-name", 1)
code, data = call("/api/v1/forward/list", other, map[string]interface{}{})
if code != 0 {
t.Fatal(code)
}
rows = nil
if err := json.Unmarshal(data, &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("old name gained rules: %s", data)
}
if code, _ := call("/api/v1/forward/reset-flow", other, map[string]interface{}{"id": 9301}); code == 0 {
t.Fatal("other user reset original rule")
}
if code, _ := call("/api/v1/forward/reset-flow", fresh, map[string]interface{}{"id": 9301}); code != 0 {
t.Fatalf("owner reset denied: %d", code)
}
})
}
})
}
}
// Each PostgreSQL test owns only its unique schema, never the shared test database.
func ownerNamesRepository(t *testing.T, database string) func() *repo.Repository {
t.Helper()
dsn := filepath.Join(t.TempDir(), "owners.db")
if database == "postgres" {
baseDSN := strings.TrimSpace(os.Getenv("FLVX_POSTGRES_TEST_DSN"))
if baseDSN == "" {
t.Skip("set FLVX_POSTGRES_TEST_DSN to run postgres owner-name contracts")
}
schema := "contract_owner_names_" + strconv.FormatInt(time.Now().UnixNano(), 36)
db, err := sql.Open("pgx", baseDSN)
if err != nil {
t.Fatal(err)
}
if _, err = db.Exec(`CREATE SCHEMA "` + schema + `"`); err != nil {
db.Close()
t.Fatal(err)
}
t.Cleanup(func() { _, _ = db.Exec(`DROP SCHEMA IF EXISTS "` + schema + `" CASCADE`); _ = db.Close() })
dsn, err = withSearchPath(baseDSN, schema)
if err != nil {
t.Fatal(err)
}
}
return func() *repo.Repository {
t.Helper()
var r *repo.Repository
var err error
if database == "postgres" {
r, err = repo.OpenPostgres(dsn)
} else {
r, err = repo.Open(dsn)
}
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
return r
}
}
func TestForwardOwnerNamesSurviveLegacyBackupAndRestart(t *testing.T) {
for _, database := range []string{"sqlite", "postgres"} {
t.Run(database, func(t *testing.T) {
open := ownerNamesRepository(t, database)
r := open()
// A legacy backup stores the rule's original name, not the user's current one.
const legacy = `{"users":[{"id":9103,"user":"current-owner","pwd":"test-only-password","roleId":1,"status":1}],
"forwards":[
{"id":9301,"userId":9103,"userName":"former-owner","name":"historical-rule","tunnelId":9201,"inFlow":123,"outFlow":456,"status":1},
{"id":9302,"userId":9199,"userName":"missing-owner","name":"orphan-rule","tunnelId":9201,"status":1},
{"id":9303,"userId":9198,"userName":"","name":"unknown-owner-rule","tunnelId":9201,"status":1}]}`
var backup model.BackupData
if err := json.Unmarshal([]byte(legacy), &backup); err != nil {
t.Fatal(err)
}
if _, err := r.Import(&backup, []string{"users", "forwards"}); err != nil {
t.Fatal(err)
}
check := func(stage string) {
t.Helper()
rows, err := r.ListForwards()
if err != nil {
t.Fatal(err)
}
if len(rows) != 3 {
t.Fatalf("%s: rows=%+v", stage, rows)
}
for i, want := range []string{"current-owner", "missing-owner", ""} {
if rows[i]["userName"] != want {
t.Fatalf("%s: row %d owner=%v want %q", stage, i, rows[i]["userName"], want)
}
}
if rows[0]["id"] != int64(9301) || rows[0]["userId"] != int64(9103) || rows[0]["inFlow"] != int64(123) || rows[0]["outFlow"] != int64(456) {
t.Fatalf("%s: rule changed: %+v", stage, rows[0])
}
previews, err := r.ListForwardsByTunnel(9201)
if err != nil {
t.Fatal(err)
}
if len(previews) != 3 || previews[0].UserName != "current-owner" || previews[1].UserName != "missing-owner" || previews[2].UserName != "" {
t.Fatalf("%s: preview owners=%+v", stage, previews)
}
exported, err := r.ExportAll()
if err != nil {
t.Fatal(err)
}
if len(exported.Forwards) != 3 || exported.Forwards[0].UserName != "former-owner" {
t.Fatalf("%s: stored legacy snapshot was rewritten: %+v", stage, exported.Forwards)
}
}
check("import")
if err := r.Close(); err != nil {
t.Fatal(err)
}
r = open()
check("restart")
// A forwards-only restore must resolve against the existing user by ID.
if _, err := r.Import(&backup, []string{"forwards"}); err != nil {
t.Fatal(err)
}
check("partial restore")
})
}
}