mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-15 17:14:23 +08:00
feat: show current owner names and persist user and limit order (#576)
Resolve current forwarding-rule owner names by user ID. Add administrator-managed persistent user and speed-limit ordering, backup compatibility, and conflict recovery. Repair PostgreSQL explicit-ID import sequences transactionally. Closes #574 Closes #575
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *Handler) userDisplayOrder(w http.ResponseWriter, r *http.Request) {
|
||||
updateDisplayOrder(w, r, h.repo.UpdateUserDisplayOrder)
|
||||
}
|
||||
func (h *Handler) speedLimitDisplayOrder(w http.ResponseWriter, r *http.Request) {
|
||||
updateDisplayOrder(w, r, h.repo.UpdateSpeedLimitDisplayOrder)
|
||||
}
|
||||
func updateDisplayOrder(w http.ResponseWriter, r *http.Request, save func([]int64, []int64) error) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.Err(400, "请求方法错误"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
ExpectedIDs []int64 `json:"expectedIds"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.Err(400, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
if err := save(req.IDs, req.ExpectedIDs); err != nil {
|
||||
code := 500
|
||||
if errors.Is(err, repo.ErrInvalidDisplayOrder) {
|
||||
code = 400
|
||||
}
|
||||
if errors.Is(err, repo.ErrDisplayOrderConflict) {
|
||||
code = 409
|
||||
}
|
||||
msg := "排序保存失败,请刷新后重试"
|
||||
if code != 500 {
|
||||
msg = err.Error()
|
||||
}
|
||||
response.WriteJSON(w, response.Err(code, msg))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
@@ -248,6 +248,8 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/forward/batch-resume", h.forwardBatchResume)
|
||||
mux.HandleFunc("/api/v1/forward/batch-redeploy", h.forwardBatchRedeploy)
|
||||
mux.HandleFunc("/api/v1/forward/batch-change-tunnel", h.forwardBatchChangeTunnel)
|
||||
mux.HandleFunc("/api/v1/user/update-order", h.userDisplayOrder)
|
||||
mux.HandleFunc("/api/v1/speed-limit/update-order", h.speedLimitDisplayOrder)
|
||||
mux.HandleFunc("/api/v1/speed-limit/list", h.speedLimitList)
|
||||
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
|
||||
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
|
||||
|
||||
@@ -180,7 +180,7 @@ func requiresAdmin(path string) bool {
|
||||
}
|
||||
|
||||
switch path {
|
||||
case "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
|
||||
case "/api/v1/user/update-order", "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
|
||||
return true
|
||||
case "/api/v1/config/update", "/api/v1/config/update-single":
|
||||
return true
|
||||
|
||||
@@ -10,6 +10,7 @@ import "database/sql"
|
||||
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
|
||||
// word, so TableName() is required for correct quoting.
|
||||
type User struct {
|
||||
DisplayOrder int64 `gorm:"not null;default:0"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
User string `gorm:"column:user;type:varchar(100);not null"`
|
||||
Pwd string `gorm:"type:varchar(100);not null"`
|
||||
@@ -151,14 +152,15 @@ type NftCounterState struct {
|
||||
func (NftCounterState) TableName() string { return "nft_counter_state" }
|
||||
|
||||
type SpeedLimit struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Speed int `gorm:"not null"`
|
||||
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
||||
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
DisplayOrder int64 `gorm:"not null;default:0"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Speed int `gorm:"not null"`
|
||||
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
||||
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (SpeedLimit) TableName() string { return "speed_limit" }
|
||||
@@ -417,6 +419,7 @@ type BackupData struct {
|
||||
}
|
||||
|
||||
type UserBackup struct {
|
||||
DisplayOrder *int64 `json:"displayOrder,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
User string `json:"user"`
|
||||
Pwd string `json:"pwd"`
|
||||
@@ -540,14 +543,15 @@ type UserTunnelBackup struct {
|
||||
}
|
||||
|
||||
type SpeedLimitBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Speed int64 `json:"speed"`
|
||||
TunnelID *int64 `json:"tunnelId,omitempty"`
|
||||
TunnelName string `json:"tunnelName,omitempty"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
DisplayOrder *int64 `json:"displayOrder,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Speed int64 `json:"speed"`
|
||||
TunnelID *int64 `json:"tunnelId,omitempty"`
|
||||
TunnelName string `json:"tunnelName,omitempty"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type TunnelGroupBackup struct {
|
||||
|
||||
@@ -914,7 +914,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var users []model.User
|
||||
if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil {
|
||||
if err := r.db.Where("role_id != ?", 0).Order("display_order ASC, id DESC").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs := make([]int64, 0, len(users))
|
||||
@@ -954,7 +954,7 @@ func (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var limits []model.SpeedLimit
|
||||
if err := r.db.Order("id DESC").Find(&limits).Error; err != nil {
|
||||
if err := r.db.Order("display_order ASC, id DESC").Find(&limits).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]map[string]interface{}, 0, len(limits))
|
||||
@@ -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").
|
||||
@@ -2092,7 +2093,8 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
|
||||
out := make([]model.UserBackup, 0, len(users))
|
||||
for _, u := range users {
|
||||
b := model.UserBackup{
|
||||
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
|
||||
DisplayOrder: &u.DisplayOrder,
|
||||
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
|
||||
ExpTime: u.ExpTime, Flow: u.Flow, FlowMiB: u.FlowMiB, InFlow: u.InFlow, OutFlow: u.OutFlow,
|
||||
FlowResetTime: u.FlowResetTime, Num: u.Num,
|
||||
CreatedTime: u.CreatedTime, Status: u.Status,
|
||||
@@ -2289,7 +2291,8 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {
|
||||
out := make([]model.SpeedLimitBackup, 0, len(sls))
|
||||
for _, sl := range sls {
|
||||
b := model.SpeedLimitBackup{
|
||||
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
|
||||
DisplayOrder: &sl.DisplayOrder,
|
||||
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
|
||||
CreatedTime: sl.CreatedTime, Status: sl.Status,
|
||||
}
|
||||
if sl.UpdatedTime.Valid {
|
||||
@@ -2372,7 +2375,24 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
|
||||
typeSet[t] = true
|
||||
}
|
||||
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
err := backupImportTransaction(r.db, func(tx *gorm.DB) error {
|
||||
result = &model.ImportResult{}
|
||||
tables, err := lockPostgresBackupTables(tx, backup, typeSet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// SQLite still needs its writer reservation for display order.
|
||||
// PostgreSQL membership locks were taken with all imported tables above.
|
||||
if tx.Dialector.Name() != "postgres" && typeSet["users"] {
|
||||
if err := lockDisplayOrder(tx, "user"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tx.Dialector.Name() != "postgres" && typeSet["speedLimits"] {
|
||||
if err := lockDisplayOrder(tx, "speed_limit"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := unixMilliNow()
|
||||
|
||||
if typeSet["users"] && len(backup.Users) > 0 {
|
||||
@@ -2445,7 +2465,7 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
|
||||
}
|
||||
result.ConfigsImported = count
|
||||
}
|
||||
return nil
|
||||
return advancePostgresBackupSequences(tx, tables)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2477,12 +2497,17 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
|
||||
Status: status,
|
||||
PasswordChangedAt: now,
|
||||
}
|
||||
// An omitted old-backup rank preserves existing order on conflict.
|
||||
// New legacy records use rank zero and ID descending, matching upgrades.
|
||||
columns := []string{"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
|
||||
"flow_reset_time", "num", "updated_time", "status", "password_changed_at"}
|
||||
if u.DisplayOrder != nil {
|
||||
item.DisplayOrder = *u.DisplayOrder
|
||||
columns = append(columns, "display_order")
|
||||
}
|
||||
err = tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
|
||||
"flow_reset_time", "num", "updated_time", "status", "password_changed_at",
|
||||
}),
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns(columns),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
return count, err
|
||||
@@ -2765,11 +2790,16 @@ func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now in
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: sl.Status,
|
||||
}
|
||||
// An omitted old-backup rank preserves existing order on conflict.
|
||||
// New legacy records use rank zero and ID descending, matching upgrades.
|
||||
columns := []string{"name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status"}
|
||||
if sl.DisplayOrder != nil {
|
||||
item.DisplayOrder = *sl.DisplayOrder
|
||||
columns = append(columns, "display_order")
|
||||
}
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status",
|
||||
}),
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns(columns),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
return count, err
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var errBackupImportBusy = errors.New("database is busy; retry the backup import")
|
||||
|
||||
// Only retry lock acquisition failures, which occur before any backup writes.
|
||||
// NOWAIT releases partial lock sets instead of deadlocking with existing
|
||||
// child-before-parent deletes or forward-before-user traffic updates.
|
||||
func backupImportTransaction(db *gorm.DB, importRows func(*gorm.DB) error) error {
|
||||
var err error
|
||||
for attempt := 0; attempt < 10; attempt++ {
|
||||
err = db.Transaction(importRows)
|
||||
if !errors.Is(err, errBackupImportBusy) {
|
||||
return err
|
||||
}
|
||||
if attempt < 9 {
|
||||
time.Sleep(min(10*time.Millisecond<<attempt, 100*time.Millisecond))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Lock every table that import can write, including generated-ID child tables.
|
||||
// No writes happen until the complete set is held. Existing CRUD transactions
|
||||
// can therefore finish regardless of their own parent/child mutation order.
|
||||
// The returned subset contains only tables receiving explicit backup IDs.
|
||||
func lockPostgresBackupTables(tx *gorm.DB, backup *model.BackupData, types map[string]bool) ([]string, error) {
|
||||
if tx.Dialector.Name() != "postgres" {
|
||||
return nil, nil
|
||||
}
|
||||
chains, grants, ports := 0, 0, 0
|
||||
for _, tunnel := range backup.Tunnels {
|
||||
chains += len(tunnel.ChainTunnels)
|
||||
}
|
||||
for _, permission := range backup.Permissions {
|
||||
grants += len(permission.Grants)
|
||||
}
|
||||
for _, forward := range backup.Forwards {
|
||||
if forward.ForwardPorts != nil {
|
||||
ports++
|
||||
}
|
||||
}
|
||||
var tables []string
|
||||
for _, item := range []struct {
|
||||
kind string
|
||||
count int
|
||||
name string
|
||||
explicitIDs bool
|
||||
}{
|
||||
{"users", len(backup.Users), "user", true},
|
||||
{"speedLimits", len(backup.SpeedLimits), "speed_limit", true},
|
||||
{"nodes", len(backup.Nodes), "node", true},
|
||||
{"tunnels", len(backup.Tunnels), "tunnel", true},
|
||||
{"tunnels", chains, "chain_tunnel", true},
|
||||
{"forwards", len(backup.Forwards), "forward", true},
|
||||
{"userTunnels", len(backup.UserTunnels), "user_tunnel", true},
|
||||
{"tunnelGroups", len(backup.TunnelGroups), "tunnel_group", true},
|
||||
{"userGroups", len(backup.UserGroups), "user_group", true},
|
||||
{"permissions", len(backup.Permissions), "group_permission", true},
|
||||
{"permissions", grants, "group_permission_grant", true},
|
||||
{"users", len(backup.Users), "user_quota", false},
|
||||
{"forwards", ports, "forward_port", false},
|
||||
{"tunnelGroups", len(backup.TunnelGroups), "tunnel_group_tunnel", false},
|
||||
{"userGroups", len(backup.UserGroups), "user_group_user", false},
|
||||
{"configs", len(FilterBackupConfigs(backup.Configs)), "vite_config", false},
|
||||
} {
|
||||
if !types[item.kind] || item.count == 0 {
|
||||
continue
|
||||
}
|
||||
// Resolve the same relation as normal unqualified repository queries,
|
||||
// even when the first search_path schema has no copy of this table.
|
||||
var relation struct {
|
||||
Schema string
|
||||
Name string
|
||||
}
|
||||
if err := tx.Raw(`SELECT n.nspname AS schema, c.relname AS name
|
||||
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.oid = pg_catalog.to_regclass(?)`, quoteSQLIdentifier(item.name)).Scan(&relation).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if relation.Name == "" {
|
||||
return nil, fmt.Errorf("missing import table %s", item.name)
|
||||
}
|
||||
table := quoteSQLIdentifier(relation.Schema) + "." + quoteSQLIdentifier(relation.Name)
|
||||
if err := tx.Exec("LOCK TABLE " + table + " IN SHARE ROW EXCLUSIVE MODE NOWAIT").Error; err != nil {
|
||||
var pgError interface{ SQLState() string }
|
||||
if errors.As(err, &pgError) && pgError.SQLState() == "55P03" {
|
||||
return nil, fmt.Errorf("%w: %w", errBackupImportBusy, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if item.explicitIDs {
|
||||
tables = append(tables, table)
|
||||
}
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func advancePostgresBackupSequences(tx *gorm.DB, tables []string) error {
|
||||
for _, table := range tables {
|
||||
if err := advancePostgresBackupSequence(tx, table); err != nil {
|
||||
return fmt.Errorf("advance imported %s id sequence: %w", table, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The caller holds the table's SHARE ROW EXCLUSIVE lock until commit. Unlike
|
||||
// startup's setval repair, RESTART is transactional and invalidates other
|
||||
// connections' cached values. Never return to IDs already allocated, even if
|
||||
// their rows were deleted or their transactions rolled back.
|
||||
func advancePostgresBackupSequence(tx *gorm.DB, table string) error {
|
||||
var maxID int64
|
||||
if err := tx.Raw("SELECT COALESCE(MAX(id), 0) FROM " + table).Scan(&maxID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if maxID <= 0 {
|
||||
return nil
|
||||
}
|
||||
type sequenceInfo struct {
|
||||
Schema string
|
||||
Name string
|
||||
Cache int64
|
||||
Increment int64
|
||||
Maximum int64
|
||||
}
|
||||
var sequences []sequenceInfo
|
||||
// Prefer the actual default's catalog dependency: a legacy custom nextval
|
||||
// default need not own its sequence, and the old owned serial may remain.
|
||||
// Identity columns have no pg_attrdef, so use their owned sequence instead.
|
||||
if err := tx.Raw(`WITH defaults AS (
|
||||
SELECT dep.refobjid AS oid FROM pg_catalog.pg_attrdef def
|
||||
JOIN pg_catalog.pg_attribute attr ON attr.attrelid = def.adrelid AND attr.attnum = def.adnum
|
||||
JOIN pg_catalog.pg_depend dep ON dep.classid = 'pg_catalog.pg_attrdef'::regclass
|
||||
AND dep.objid = def.oid AND dep.refclassid = 'pg_catalog.pg_class'::regclass
|
||||
JOIN pg_catalog.pg_sequence seq ON seq.seqrelid = dep.refobjid
|
||||
WHERE def.adrelid = ?::regclass AND attr.attname = 'id'
|
||||
), candidates AS (
|
||||
SELECT oid FROM defaults
|
||||
UNION SELECT pg_catalog.pg_get_serial_sequence(?, 'id')::regclass WHERE NOT EXISTS (SELECT 1 FROM defaults)
|
||||
)
|
||||
SELECT n.nspname AS schema, c.relname AS name,
|
||||
s.seqcache AS cache, s.seqincrement AS increment, s.seqmax AS maximum
|
||||
FROM pg_catalog.pg_sequence s
|
||||
JOIN pg_catalog.pg_class c ON c.oid = s.seqrelid
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE s.seqrelid IN (SELECT oid FROM candidates)`, table, table).Scan(&sequences).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sequences) != 1 || sequences[0].Increment <= 0 {
|
||||
return fmt.Errorf("missing or ambiguous ascending ID sequence")
|
||||
}
|
||||
sequence := sequences[0]
|
||||
name := quoteSQLIdentifier(sequence.Schema) + "." + quoteSQLIdentifier(sequence.Name)
|
||||
// Reapplying CACHE without changing its setting takes a sequence DDL lock.
|
||||
// Read last_value only after that lock: even direct concurrent nextval calls
|
||||
// must finish first. The table locks already exclude normal INSERTs.
|
||||
if err := tx.Exec(fmt.Sprintf("ALTER SEQUENCE %s CACHE %d", name, sequence.Cache)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var state struct {
|
||||
LastValue int64
|
||||
IsCalled bool
|
||||
}
|
||||
if err := tx.Raw("SELECT last_value, is_called FROM " + name).Scan(&state).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if state.LastValue < 1 {
|
||||
return fmt.Errorf("ID sequence must allocate positive IDs")
|
||||
}
|
||||
if maxID >= sequence.Maximum || (state.IsCalled && state.LastValue > sequence.Maximum-sequence.Increment) {
|
||||
return fmt.Errorf("ID sequence exhausted")
|
||||
}
|
||||
next := state.LastValue
|
||||
if state.IsCalled {
|
||||
next += sequence.Increment
|
||||
}
|
||||
if next <= maxID {
|
||||
steps := (maxID-next)/sequence.Increment + 1
|
||||
if steps > (sequence.Maximum-next)/sequence.Increment {
|
||||
return fmt.Errorf("ID sequence exhausted")
|
||||
}
|
||||
next += steps * sequence.Increment
|
||||
}
|
||||
return tx.Exec(fmt.Sprintf("ALTER SEQUENCE %s RESTART WITH %d", name, next)).Error
|
||||
}
|
||||
@@ -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,109 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"gorm.io/gorm"
|
||||
"slices"
|
||||
)
|
||||
|
||||
var ErrDisplayOrderConflict = errors.New("列表已变更,请刷新后重试")
|
||||
var ErrInvalidDisplayOrder = errors.New("排序必须包含全部条目且不能重复")
|
||||
|
||||
// Lock the entire membership, including an empty list and concurrent INSERTs.
|
||||
// PostgreSQL row locks alone cannot protect the missing-row/phantom case.
|
||||
// SQLite's first write acquires its writer reservation before reading the list.
|
||||
func lockDisplayOrder(tx *gorm.DB, table string) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
return tx.Exec("LOCK TABLE \"" + table + "\" IN SHARE ROW EXCLUSIVE MODE").Error
|
||||
}
|
||||
return tx.Table(table).Where("1 = 0").UpdateColumn("display_order", gorm.Expr("display_order")).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateUserDisplayOrder(ids, expected []int64) error {
|
||||
return r.updateDisplayOrder("user", ids, expected)
|
||||
}
|
||||
func (r *Repository) UpdateSpeedLimitDisplayOrder(ids, expected []int64) error {
|
||||
return r.updateDisplayOrder("speed_limit", ids, expected)
|
||||
}
|
||||
func (r *Repository) updateDisplayOrder(table string, ids, expected []int64) error {
|
||||
if ids == nil || expected == nil {
|
||||
return ErrInvalidDisplayOrder
|
||||
}
|
||||
seen := map[int64]bool{}
|
||||
for _, id := range ids {
|
||||
if id <= 0 || seen[id] {
|
||||
return ErrInvalidDisplayOrder
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
seenExpected := map[int64]bool{}
|
||||
for _, id := range expected {
|
||||
if id <= 0 || seenExpected[id] {
|
||||
return ErrInvalidDisplayOrder
|
||||
}
|
||||
seenExpected[id] = true
|
||||
}
|
||||
if len(ids) != len(expected) {
|
||||
return ErrInvalidDisplayOrder
|
||||
}
|
||||
for id := range seen {
|
||||
if !seenExpected[id] {
|
||||
return ErrInvalidDisplayOrder
|
||||
}
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockDisplayOrder(tx, table); err != nil {
|
||||
return err
|
||||
}
|
||||
q := tx.Table(table)
|
||||
if table == "user" {
|
||||
var admins int64
|
||||
if err := tx.Table(table).Where("role_id = ? AND id IN ?", 0, ids).Count(&admins).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if admins != 0 {
|
||||
return ErrInvalidDisplayOrder
|
||||
}
|
||||
q = q.Where("role_id != ?", 0)
|
||||
}
|
||||
var current []int64
|
||||
if err := q.Order("display_order ASC, id DESC").Pluck("id", ¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Equal(current, expected) {
|
||||
return ErrDisplayOrderConflict
|
||||
}
|
||||
for i, id := range ids {
|
||||
result := tx.Table(table).Where("id = ?", id).UpdateColumn("display_order", int64(i))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrDisplayOrderConflict
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// New items precede all saved ranks. Zero is only the legacy migration default.
|
||||
func createFirst(tx *gorm.DB, table string, item interface{}, rank *int64) error {
|
||||
return tx.Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockDisplayOrder(tx, table); err != nil {
|
||||
return err
|
||||
}
|
||||
q := tx.Table(table)
|
||||
if table == "user" {
|
||||
q = q.Where("role_id != ?", 0)
|
||||
}
|
||||
var first int64
|
||||
if err := q.Select("COALESCE(MIN(display_order), 0)").Scan(&first).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if first == -1<<63 {
|
||||
return errors.New("display order exhausted")
|
||||
}
|
||||
*rank = first - 1
|
||||
return tx.Create(item).Error
|
||||
})
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
|
||||
Status: status,
|
||||
PasswordChangedAt: now,
|
||||
}
|
||||
if err := r.db.Create(&user).Error; err != nil {
|
||||
if err := createFirst(r.db, "user", &user, &user.DisplayOrder); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return user.ID, nil
|
||||
@@ -940,7 +940,7 @@ func (r *Repository) CreateSpeedLimit(name string, speed int, now int64, status
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: status,
|
||||
}
|
||||
if err := r.db.Create(&sl).Error; err != nil {
|
||||
if err := createFirst(r.db, "speed_limit", &sl, &sl.DisplayOrder); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return sl.ID, nil
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func orderRequest(t *testing.T, router http.Handler, token, resource, body string) response.R {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/"+resource+"/update-order", bytes.NewBufferString(body))
|
||||
req.Header.Set("Authorization", token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
var out response.R
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
func TestDisplayOrderUserContract(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "order-secret")
|
||||
token := mustAdminToken(t, "order-secret")
|
||||
a, err := r.CreateUser("order-a", "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := r.CreateUser("order-b", "hash", 1, 0, 20, 1, 2, 1, 3, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := orderRequest(t, router, token, "user", fmt.Sprintf(`{"ids":[%d,%d],"expectedIds":[%d,%d]}`, a, b, b, a))
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("reorder: %+v", out)
|
||||
}
|
||||
users, err := r.ListUsers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := []int64{users[0]["id"].(int64), users[1]["id"].(int64)}; !reflect.DeepEqual(got, []int64{a, b}) {
|
||||
t.Fatalf("order %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayOrderValidationAndLifecycle(t *testing.T) {
|
||||
for _, resource := range []string{"user", "speed-limit"} {
|
||||
t.Run(resource, func(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "order-secret")
|
||||
token := mustAdminToken(t, "order-secret")
|
||||
create := func(name string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
var err error
|
||||
if resource == "user" {
|
||||
id, err = r.CreateUser(name, "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||
} else {
|
||||
id, err = r.CreateSpeedLimit(name, 100, 0, 1)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
list := func() []int64 {
|
||||
t.Helper()
|
||||
out := postBatchRequest(t, router, token, "/api/v1/"+resource+"/list", "{}")
|
||||
rows, ok := out.Data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("list data: %T", out.Data)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, row := range rows {
|
||||
ids = append(ids, int64(row.(map[string]interface{})["id"].(float64)))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
a, b := create("a"), create("b")
|
||||
body := func(ids, expected []int64) string {
|
||||
v, _ := json.Marshal(map[string]interface{}{"ids": ids, "expectedIds": expected})
|
||||
return string(v)
|
||||
}
|
||||
check := func(payload string, code int) {
|
||||
t.Helper()
|
||||
out := orderRequest(t, router, token, resource, payload)
|
||||
if out.Code != code {
|
||||
t.Fatalf("want %d got %+v", code, out)
|
||||
}
|
||||
}
|
||||
check(body([]int64{a, b}, []int64{b, a}), 0)
|
||||
for _, payload := range []string{`{}`, `{"ids":null,"expectedIds":[]}`, `{"ids":[1.1],"expectedIds":[1]}`, body([]int64{a, a}, []int64{a, b}), body([]int64{a}, []int64{a, b}), body([]int64{a, b, 99999}, []int64{a, b}), body([]int64{a, 99999}, []int64{a, b}), body([]int64{a, b}, []int64{a, a})} {
|
||||
check(payload, 400)
|
||||
}
|
||||
if resource == "user" {
|
||||
check(body([]int64{1, a, b}, []int64{a, b}), 400)
|
||||
check(body([]int64{1, a, b}, []int64{1, a, b}), 400)
|
||||
}
|
||||
check(body([]int64{b, a}, []int64{b, a}), 409)
|
||||
c := create("c")
|
||||
if got := list(); !reflect.DeepEqual(got, []int64{c, a, b}) {
|
||||
t.Fatalf("new item first: %v", got)
|
||||
}
|
||||
check(body([]int64{b, a}, []int64{a, b}), 409)
|
||||
if resource == "user" {
|
||||
if err := r.UpdateUserWithoutPassword(a, "edited", 10, 2, 0, 1, 1, 3, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := r.UpdateSpeedLimit(a, "edited", 200, 1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := list(); !reflect.DeepEqual(got, []int64{c, a, b}) {
|
||||
t.Fatalf("edit changed order: %v", got)
|
||||
}
|
||||
if got := orderRequest(t, router, "", resource, body([]int64{b, a, c}, []int64{c, a, b})); got.Code != 401 {
|
||||
t.Fatalf("missing auth: %+v", got)
|
||||
}
|
||||
normal, err := auth.GenerateToken(a, "edited", 1, "order-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Speed-limit fixture needs a real ordinary account for auth lookup.
|
||||
if resource == "speed-limit" {
|
||||
uid, err := r.CreateUser("normal", "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normal, err = auth.GenerateToken(uid, "normal", 1, "order-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := orderRequest(t, router, normal, resource, body([]int64{b, a, c}, []int64{c, a, b})); got.Code != 403 {
|
||||
t.Fatalf("non-admin auth: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDisplayOrderBackupAndRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "order.db")
|
||||
r, err := repo.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := r.CreateSpeedLimit("a", 100, 0, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := r.CreateSpeedLimit("b", 200, 0, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.UpdateSpeedLimitDisplayOrder([]int64{a, b}, []int64{b, a}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check := func(r *repo.Repository) {
|
||||
t.Helper()
|
||||
rows, err := r.ListSpeedLimits()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row["id"].(int64))
|
||||
}
|
||||
if !reflect.DeepEqual(ids, []int64{a, b}) {
|
||||
t.Fatalf("order: %v", ids)
|
||||
}
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err = repo.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
check(r)
|
||||
backup, err := r.ExportPartial([]string{"speedLimits"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dest, err := repo.Open(filepath.Join(t.TempDir(), "restore.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer dest.Close()
|
||||
if _, err := dest.Import(backup, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(dest)
|
||||
}
|
||||
|
||||
// Lifecycle and migration fixtures use DDL only to reproduce a pre-upgrade
|
||||
// database and inject a write failure. All observations use public interfaces.
|
||||
func TestDisplayOrderLifecycleDialects(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
open := orderTestDatabase(t, dialect)
|
||||
r := open()
|
||||
for _, resource := range []string{"user", "speed_limit"} {
|
||||
t.Run(resource, func(t *testing.T) {
|
||||
create := func(name string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
var err error
|
||||
if resource == "user" {
|
||||
id, err = r.CreateUser(name, security.MD5("test-password"), 1, 0, 123, 1, 4, 1, 5, 0)
|
||||
} else {
|
||||
id, err = r.CreateSpeedLimit(name, 123, 0, 1)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
list := func(db *repo.Repository) []int64 {
|
||||
t.Helper()
|
||||
var rows []map[string]interface{}
|
||||
var err error
|
||||
if resource == "user" {
|
||||
rows, err = db.ListUsers()
|
||||
} else {
|
||||
rows, err = db.ListSpeedLimits()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row["id"].(int64))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
save := func(db *repo.Repository, ids, expected []int64) error {
|
||||
if resource == "user" {
|
||||
return db.UpdateUserDisplayOrder(ids, expected)
|
||||
}
|
||||
return db.UpdateSpeedLimitDisplayOrder(ids, expected)
|
||||
}
|
||||
a, b, c := create("a"), create("b"), create("c")
|
||||
// Drop the new column to reproduce a database last opened by the old release.
|
||||
if err := r.DB().Exec("ALTER TABLE \"" + resource + "\" DROP COLUMN display_order").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = open()
|
||||
if got := list(r); !reflect.DeepEqual(got, []int64{c, b, a}) {
|
||||
t.Fatalf("legacy order %v", got)
|
||||
}
|
||||
if err := save(r, []int64{a, c, b}, []int64{c, b, a}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup, err := r.ExportPartial([]string{"users", "speedLimits"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restore := orderTestDatabase(t, dialect)()
|
||||
defer restore.Close()
|
||||
types := []string{"speedLimits"}
|
||||
if resource == "user" {
|
||||
types = []string{"users"}
|
||||
}
|
||||
if _, err := restore.Import(backup, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := list(restore); !reflect.DeepEqual(got, []int64{a, c, b}) {
|
||||
t.Fatalf("new backup order: %v", got)
|
||||
}
|
||||
before, err := r.ExportPartial([]string{"users", "speedLimits"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = open()
|
||||
second := open()
|
||||
defer second.Close()
|
||||
if got := list(second); !reflect.DeepEqual(got, []int64{a, c, b}) {
|
||||
t.Fatalf("cross-client/restart order %v", got)
|
||||
}
|
||||
// The next save fails on its second row; the first row must roll back.
|
||||
if dialect == "sqlite" {
|
||||
if err := r.DB().Exec(fmt.Sprintf("CREATE TRIGGER reject_order BEFORE UPDATE OF display_order ON \"%s\" WHEN NEW.id = %d BEGIN SELECT RAISE(ABORT, 'injected failure'); END", resource, a)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := r.DB().Exec(fmt.Sprintf("ALTER TABLE \"%s\" ADD CONSTRAINT reject_order CHECK (id != %d OR display_order = 0)", resource, a)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := save(r, []int64{b, a, c}, []int64{a, c, b}); err == nil {
|
||||
t.Fatal("expected injected failure")
|
||||
}
|
||||
if got := list(second); !reflect.DeepEqual(got, []int64{a, c, b}) {
|
||||
t.Fatalf("partial write: %v", got)
|
||||
}
|
||||
if dialect == "sqlite" {
|
||||
if err := r.DB().Exec("DROP TRIGGER reject_order").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := r.DB().Exec("ALTER TABLE \"" + resource + "\" DROP CONSTRAINT reject_order").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Exactly one cross-client save against the same observed order may win.
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
for _, db := range []*repo.Repository{r, second} {
|
||||
go func(db *repo.Repository) { <-start; results <- save(db, []int64{b, a, c}, []int64{a, c, b}) }(db)
|
||||
}
|
||||
close(start)
|
||||
e1, e2 := <-results, <-results
|
||||
if !((e1 == nil && errors.Is(e2, repo.ErrDisplayOrderConflict)) || (e2 == nil && errors.Is(e1, repo.ErrDisplayOrderConflict))) {
|
||||
t.Fatalf("concurrent saves: %v / %v", e1, e2)
|
||||
}
|
||||
// Reordering must not mutate any exported fields besides rank.
|
||||
after, err := r.ExportPartial([]string{"users", "speedLimits"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
strip := func(b *model.BackupData) {
|
||||
b.ExportedAt = 0
|
||||
for i := range b.Users {
|
||||
b.Users[i].DisplayOrder = nil
|
||||
}
|
||||
for i := range b.SpeedLimits {
|
||||
b.SpeedLimits[i].DisplayOrder = nil
|
||||
}
|
||||
}
|
||||
strip(before)
|
||||
strip(after)
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Fatal("reorder modified non-order backup fields")
|
||||
}
|
||||
// Old backups omit ranks and must not overwrite an existing saved order.
|
||||
legacy := orderTestDatabase(t, dialect)()
|
||||
defer legacy.Close()
|
||||
if _, err := legacy.Import(after, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := list(legacy); !reflect.DeepEqual(got, []int64{c, b, a}) {
|
||||
t.Fatalf("new legacy records: %v", got)
|
||||
}
|
||||
if _, err := r.Import(after, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := list(r); !reflect.DeepEqual(got, []int64{b, a, c}) {
|
||||
t.Fatalf("old backup reset existing order: %v", got)
|
||||
}
|
||||
d := create("d")
|
||||
if got := list(r); !reflect.DeepEqual(got, []int64{d, b, a, c}) {
|
||||
t.Fatalf("create after reorder: %v", got)
|
||||
}
|
||||
if err := save(second, []int64{a, b, c}, []int64{b, a, c}); !errors.Is(err, repo.ErrDisplayOrderConflict) {
|
||||
t.Fatalf("stale membership: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
r.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func orderTestDatabase(t *testing.T, dialect string) func() *repo.Repository {
|
||||
t.Helper()
|
||||
if dialect == "sqlite" {
|
||||
path := filepath.Join(t.TempDir(), "lifecycle.db")
|
||||
return func() *repo.Repository {
|
||||
r, err := repo.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { r.Close() })
|
||||
return r
|
||||
}
|
||||
}
|
||||
dsn := os.Getenv("FLVX_POSTGRES_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set FLVX_POSTGRES_TEST_DSN for isolated PostgreSQL contracts")
|
||||
}
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schema := "contract_display_order_" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
if _, err := admin.Exec("CREATE SCHEMA \"" + schema + "\""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { admin.Exec("DROP SCHEMA \"" + schema + "\" CASCADE"); admin.Close() })
|
||||
scoped, err := withSearchPath(dsn, schema)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return func() *repo.Repository {
|
||||
r, err := repo.OpenPostgres(scoped)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { r.Close() })
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayOrderConcurrentCreate(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
for _, resource := range []string{"user", "speed-limit"} {
|
||||
t.Run(resource, func(t *testing.T) {
|
||||
open := orderTestDatabase(t, dialect)
|
||||
r, other := open(), open()
|
||||
create := func(db *repo.Repository, name string) (int64, error) {
|
||||
if resource == "user" {
|
||||
return db.CreateUser(name, "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||
}
|
||||
return db.CreateSpeedLimit(name, 100, 0, 1)
|
||||
}
|
||||
save := func(ids, expected []int64) error {
|
||||
if resource == "user" {
|
||||
return r.UpdateUserDisplayOrder(ids, expected)
|
||||
}
|
||||
return r.UpdateSpeedLimitDisplayOrder(ids, expected)
|
||||
}
|
||||
a, err := create(r, "a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := create(r, "b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := make(chan struct{})
|
||||
saved := make(chan error, 1)
|
||||
type creation struct {
|
||||
id int64
|
||||
err error
|
||||
}
|
||||
created := make(chan creation, 1)
|
||||
go func() { <-start; saved <- save([]int64{a, b}, []int64{b, a}) }()
|
||||
go func() { <-start; id, err := create(other, "c"); created <- creation{id, err} }()
|
||||
close(start)
|
||||
saveErr, c := <-saved, <-created
|
||||
if c.err != nil {
|
||||
t.Fatal(c.err)
|
||||
}
|
||||
if saveErr != nil && !errors.Is(saveErr, repo.ErrDisplayOrderConflict) {
|
||||
t.Fatalf("concurrent save: %v", saveErr)
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if resource == "user" {
|
||||
rows, err = r.ListUsers()
|
||||
} else {
|
||||
rows, err = r.ListSpeedLimits()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := []int64{}
|
||||
for _, row := range rows {
|
||||
got = append(got, row["id"].(int64))
|
||||
}
|
||||
want := []int64{c.id, a, b}
|
||||
if saveErr != nil {
|
||||
want = []int64{c.id, b, a}
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("concurrent create order %v want %v", got, want)
|
||||
}
|
||||
if resource == "user" {
|
||||
err = r.DeleteUserCascade(c.id)
|
||||
} else {
|
||||
err = r.DeleteSpeedLimit(c.id)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := save(got, got); !errors.Is(err, repo.ErrDisplayOrderConflict) {
|
||||
t.Fatalf("deleted membership not rejected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"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 TestPostgresBackupImportAllowsImmediateCreate(t *testing.T) {
|
||||
source := ownerNamesRepository(t, "postgres")()
|
||||
for _, name := range []string{"backup-one", "backup-two"} {
|
||||
if _, err := source.CreateUser(name, security.MD5("test-password"), 1, 0, 10, 1, 2, 1, 3, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := source.CreateSpeedLimit(name, 100, 0, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backup, err := source.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := ownerNamesRepository(t, "postgres")()
|
||||
if _, err := target.Import(backup, []string{"users", "speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, err := target.CreateUser("after-import", security.MD5("test-password"), 1, 0, 10, 1, 2, 1, 3, 0); err != nil || id <= 2 {
|
||||
t.Errorf("create user immediately after backup import: id=%d err=%v", id, err)
|
||||
}
|
||||
if id, err := target.CreateSpeedLimit("after-import", 100, 0, 1); err != nil || id <= 2 {
|
||||
t.Errorf("create speed limit immediately after backup import: id=%d err=%v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportPreservesAllocatedIDs(t *testing.T) {
|
||||
for _, called := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("called=%t", called), func(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
// A deleted or rolled-back allocation may be higher than every row.
|
||||
for _, table := range []string{"user", "speed_limit"} {
|
||||
if err := r.DB().Exec(`SELECT setval(pg_get_serial_sequence(?, 'id'), 500, ?)`, `"`+table+`"`, called).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b := &model.BackupData{Users: []model.UserBackup{{ID: 1, User: "restored", Pwd: "$2-test-fixture", RoleID: 1}}, SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "restored"}}}
|
||||
if _, err := r.Import(b, []string{"users", "speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := int64(500)
|
||||
if called {
|
||||
want++
|
||||
}
|
||||
if id, err := r.CreateUser("new", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != want {
|
||||
t.Fatalf("user id=%d, want %d; err=%v", id, want, err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != want {
|
||||
t.Fatalf("speed limit id=%d, want %d; err=%v", id, want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportInvalidatesCachedIDs(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq CACHE 20`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool, err := r.DB().DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cached, err := pool.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cached.Close()
|
||||
// Hold this backend out of the pool with unused cached IDs 2..20.
|
||||
var allocated int64
|
||||
if err := cached.QueryRowContext(context.Background(), `SELECT nextval('speed_limit_id_seq')`).Scan(&allocated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if allocated != 1 {
|
||||
t.Fatal(allocated)
|
||||
}
|
||||
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 2, Name: "restored"}}}
|
||||
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Return only the cached backend to the pool, so the public create must use it.
|
||||
pool.SetMaxIdleConns(0)
|
||||
pool.SetMaxIdleConns(1)
|
||||
if err := cached.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id <= 20 {
|
||||
t.Fatalf("cached ID reused: id=%d err=%v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportRollsBackSequenceOnCommitFailure(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
// Deferred validation fails after import has already advanced the sequence.
|
||||
for _, query := range []string{
|
||||
`CREATE FUNCTION reject_import_commit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'reject imported row at commit'; END $$`,
|
||||
`CREATE CONSTRAINT TRIGGER reject_import_commit AFTER INSERT ON speed_limit DEFERRABLE INITIALLY DEFERRED FOR EACH ROW WHEN (NEW.name = 'fail-at-commit') EXECUTE FUNCTION reject_import_commit()`,
|
||||
} {
|
||||
if err := r.DB().Exec(query).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 100, Name: "fail-at-commit"}}}
|
||||
if _, err := r.Import(b, []string{"speedLimits"}); err == nil || !strings.Contains(err.Error(), "reject imported row at commit") {
|
||||
t.Fatalf("expected commit failure, got %v", err)
|
||||
}
|
||||
exported, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(exported.SpeedLimits) != 0 {
|
||||
t.Fatal("failed import persisted rows")
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("after-failure", 100, 0, 1); err != nil || id != 1 {
|
||||
t.Fatalf("failed import changed next allocation: id=%d err=%v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportPreservesSequenceIncrement(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq INCREMENT BY 10`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 25, Name: "restored"}}}
|
||||
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 31 {
|
||||
t.Fatalf("changed ID progression: id=%d, want 31; err=%v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportAdvancesEveryExplicitIDTable(t *testing.T) {
|
||||
source := ownerNamesRepository(t, "postgres")()
|
||||
types := []string{"users", "speedLimits", "nodes", "tunnels", "forwards", "userTunnels", "tunnelGroups", "userGroups", "permissions", "configs"}
|
||||
fixture := &model.BackupData{
|
||||
Users: []model.UserBackup{{ID: 1, User: "old", Pwd: "$2-test-fixture", RoleID: 1}},
|
||||
SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "old"}},
|
||||
Nodes: []model.NodeBackup{{ID: 1, Name: "old", Secret: "old-node"}},
|
||||
Tunnels: []model.TunnelBackup{{ID: 1, Name: "old", ChainTunnels: []model.ChainTunnelBackup{{ID: 1, TunnelID: 1, NodeID: 1, ChainType: "1"}}}},
|
||||
Forwards: []model.ForwardBackup{{ID: 1, Name: "old", UserID: 1, TunnelID: 1}},
|
||||
UserTunnels: []model.UserTunnelBackup{{ID: 1, UserID: 1, TunnelID: 1}},
|
||||
TunnelGroups: []model.TunnelGroupBackup{{ID: 1, Name: "old"}},
|
||||
UserGroups: []model.UserGroupBackup{{ID: 1, Name: "old"}},
|
||||
Permissions: []model.PermissionBackup{{ID: 1, UserGroupID: 1, TunnelGroupID: 1, Grants: []model.PermissionGrantBackup{{ID: 1, UserGroupID: 1, TunnelGroupID: 1, UserTunnelID: 1}}}},
|
||||
}
|
||||
if _, err := source.Import(fixture, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup, err := source.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
if _, err := r.Import(backup, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Use the normal public creation methods, including the nested child tables.
|
||||
for _, create := range []struct {
|
||||
name string
|
||||
run func() error
|
||||
}{
|
||||
{"user", func() error { _, err := r.CreateUser("new", "hash", 1, 0, 0, 0, 0, 1, 0, 0); return err }},
|
||||
{"speed limit", func() error { _, err := r.CreateSpeedLimit("new", 100, 0, 1); return err }},
|
||||
{"node", func() error {
|
||||
return r.CreateRemoteNode("new", "new-node", "127.0.0.1", "10000-20000", 0, 1, 0, "", "", "")
|
||||
}},
|
||||
{"tunnel", func() error { _, err := r.CreateTunnelTx(r.DB(), "new", 1, 1, 0, 0, 1, nil, 0, "", "", 0); return err }},
|
||||
{"chain tunnel", func() error { return r.CreateChainTunnelTx(r.DB(), 1, "1", 2, sql.NullInt64{}, "", 1, "tls", "") }},
|
||||
{"forward", func() error {
|
||||
_, err := r.CreateForwardTx(1, "old", "new", 1, "127.0.0.1:80", "", 0, 0, nil, 0, "", nil, 0, 0, nil, 0, 0, 0)
|
||||
return err
|
||||
}},
|
||||
{"user tunnel", func() error { return r.InsertUserTunnel(2, 1, nil, 0, 0, 0, 0, 1) }},
|
||||
{"tunnel group", func() error { return r.GroupCreate("tunnel_group", "new", 1, 0) }},
|
||||
{"user group", func() error { return r.GroupCreate("user_group", "new", 1, 0) }},
|
||||
{"permission", func() error { return r.InsertGroupPermission(2, 2, 0) }},
|
||||
{"permission grant", func() error { r.InsertGroupPermissionGrant(1, 1, 2, 1, 0); return nil }},
|
||||
} {
|
||||
t.Run(create.name, func(t *testing.T) {
|
||||
if err := create.run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
got, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string][]int64{}
|
||||
for _, v := range got.Users {
|
||||
ids["user"] = append(ids["user"], v.ID)
|
||||
}
|
||||
for _, v := range got.SpeedLimits {
|
||||
ids["speed limit"] = append(ids["speed limit"], v.ID)
|
||||
}
|
||||
for _, v := range got.Nodes {
|
||||
ids["node"] = append(ids["node"], v.ID)
|
||||
}
|
||||
for _, v := range got.Tunnels {
|
||||
ids["tunnel"] = append(ids["tunnel"], v.ID)
|
||||
for _, child := range v.ChainTunnels {
|
||||
ids["chain tunnel"] = append(ids["chain tunnel"], child.ID)
|
||||
}
|
||||
}
|
||||
for _, v := range got.Forwards {
|
||||
ids["forward"] = append(ids["forward"], v.ID)
|
||||
}
|
||||
for _, v := range got.UserTunnels {
|
||||
ids["user tunnel"] = append(ids["user tunnel"], v.ID)
|
||||
}
|
||||
for _, v := range got.TunnelGroups {
|
||||
ids["tunnel group"] = append(ids["tunnel group"], v.ID)
|
||||
}
|
||||
for _, v := range got.UserGroups {
|
||||
ids["user group"] = append(ids["user group"], v.ID)
|
||||
}
|
||||
for _, v := range got.Permissions {
|
||||
ids["permission"] = append(ids["permission"], v.ID)
|
||||
for _, child := range v.Grants {
|
||||
ids["permission grant"] = append(ids["permission grant"], child.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) != 11 {
|
||||
t.Fatalf("missing tables in export: %v", ids)
|
||||
}
|
||||
for name, values := range ids {
|
||||
slices.Sort(values)
|
||||
if !slices.Equal(values, []int64{1, 2}) {
|
||||
t.Errorf("%s: expected old and new IDs [1 2], got %v", name, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportKeepsExistingRowsAndEmptySelections(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
// Fixture simulates an existing explicit-ID row with an unsynchronized sequence.
|
||||
if err := r.DB().Create(&model.SpeedLimit{ID: 500, Name: "existing"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "legacy"}}}
|
||||
if _, err := r.Import(old, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 501 {
|
||||
t.Fatalf("existing maximum ignored: id=%d err=%v", id, err)
|
||||
}
|
||||
for _, b := range []*model.BackupData{{}, {Users: []model.UserBackup{{ID: 900, User: "not-selected", Pwd: "$2-test-fixture"}}}} {
|
||||
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if id, err := r.CreateUser("first-user", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != 2 {
|
||||
t.Fatalf("unselected sequence changed: id=%d err=%v", id, err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("after-empty", 100, 0, 1); err != nil || id != 502 {
|
||||
t.Fatalf("empty import changed allocation: id=%d err=%v", id, err)
|
||||
}
|
||||
got, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Users) != 2 || len(got.SpeedLimits) != 4 || got.SpeedLimits[1].Name != "existing" {
|
||||
t.Fatalf("existing or unselected data changed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportResolvesLegacySequenceAndSearchPath(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
pool, err := r.DB().DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool.SetMaxOpenConns(1)
|
||||
var schema string
|
||||
if err := r.DB().Raw(`SELECT current_schema()`).Scan(&schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prefix := "Empty-Prefix-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
if err := r.DB().Exec(`CREATE SCHEMA "` + prefix + `"`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.DB().Exec(`DROP SCHEMA "` + prefix + `" CASCADE`).Error })
|
||||
// The historical default uses a custom, unowned sequence; the original
|
||||
// owned serial sequence still exists but is no longer used by INSERT.
|
||||
for _, query := range []string{
|
||||
`CREATE SEQUENCE "Custom Limit Sequence"`,
|
||||
`ALTER TABLE speed_limit ALTER COLUMN id SET DEFAULT nextval('"Custom Limit Sequence"'::regclass)`,
|
||||
`SET search_path TO "` + prefix + `", "` + schema + `"`,
|
||||
} {
|
||||
if err := r.DB().Exec(query).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "legacy"}}}
|
||||
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 2 {
|
||||
t.Fatalf("wrong sequence or schema: id=%d err=%v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForBackupDBCondition(t *testing.T, r *repo.Repository, query string, args ...interface{}) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var ready bool
|
||||
if err := r.DB().Raw(query, args...).Scan(&ready).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ready {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("database barrier was not reached")
|
||||
}
|
||||
|
||||
func backupImportBarrier(t *testing.T, r *repo.Repository, table, timing, event, condition string) (*sql.Tx, int64) {
|
||||
t.Helper()
|
||||
pool, err := r.DB().DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
barrier, err := pool.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = barrier.Rollback() })
|
||||
key := time.Now().UnixNano() & 0x3fffffff
|
||||
if _, err := barrier.Exec(`SELECT pg_advisory_xact_lock($1)`, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guardEnd := ""
|
||||
if condition != "" {
|
||||
guardEnd = "END IF;"
|
||||
}
|
||||
function := fmt.Sprintf(`CREATE FUNCTION pause_backup_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN %s PERFORM pg_advisory_xact_lock(%d); %s RETURN NEW; END $$`, condition, key, guardEnd)
|
||||
if err := r.DB().Exec(function).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trigger := fmt.Sprintf(`CREATE TRIGGER pause_backup_test %s %s ON %s FOR EACH ROW EXECUTE FUNCTION pause_backup_test()`, timing, event, table)
|
||||
if err := r.DB().Exec(trigger).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return barrier, key
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportSerializesConcurrentCreatesAndImports(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
barrier, key := backupImportBarrier(t, r, "speed_limit", "BEFORE", "INSERT", "IF NEW.name = 'hold-import' THEN")
|
||||
first := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := r.Import(&model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 100, Name: "hold-import"}}}, []string{"speedLimits"})
|
||||
first <- err
|
||||
}()
|
||||
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=? AND NOT granted)`, key)
|
||||
created := make(chan error, 1)
|
||||
go func() {
|
||||
id, err := r.CreateSpeedLimit("concurrent-create", 100, 0, 1)
|
||||
if err == nil && id <= 100 {
|
||||
err = fmt.Errorf("reused imported ID: %d", id)
|
||||
}
|
||||
created <- err
|
||||
}()
|
||||
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE relation='speed_limit'::regclass AND NOT granted)`)
|
||||
second := make(chan error, 1)
|
||||
go func() {
|
||||
result, err := r.Import(&model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 200, Name: "second-import"}}}, []string{"speedLimits"})
|
||||
if err == nil && result.SpeedLimitsImported != 1 {
|
||||
err = fmt.Errorf("wrong retry count: %+v", result)
|
||||
}
|
||||
second <- err
|
||||
}()
|
||||
if err := barrier.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, done := range map[string]<-chan error{"first import": first, "create": created, "second import": second} {
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("%s blocked", name)
|
||||
}
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("after-both", 100, 0, 1); err != nil || id <= 200 {
|
||||
t.Fatalf("second import sequence not advanced: id=%d err=%v", id, err)
|
||||
}
|
||||
got, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.SpeedLimits) != 4 {
|
||||
t.Fatalf("lost concurrent rows: %+v", got.SpeedLimits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportAvoidsCascadeDeleteDeadlock(t *testing.T) {
|
||||
for _, mode := range []string{"retry-success", "busy-bounded"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
user, err := r.CreateUser("delete-me", "hash", 1, 0, 0, 0, 0, 1, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forward, err := r.CreateForwardTx(user, "delete-me", "old", 1, "127.0.0.1:80", "", 0, 0, []int64{1}, 12345, "", nil, 0, 0, nil, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Pause a real public cascade after deleting a child row, before it
|
||||
// reaches forward/user. Import must not retain partial parent locks.
|
||||
barrier, key := backupImportBarrier(t, r, "forward_port", "AFTER", "DELETE", "")
|
||||
deleted := make(chan error, 1)
|
||||
go func() { deleted <- r.DeleteUserCascade(user) }()
|
||||
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=? AND NOT granted)`, key)
|
||||
emptyPorts := []model.ForwardPortBackup{}
|
||||
backup := &model.BackupData{
|
||||
Users: []model.UserBackup{{ID: 100, User: "restored", Pwd: "$2-test-fixture", RoleID: 1}},
|
||||
Forwards: []model.ForwardBackup{{ID: forward, UserID: 100, Name: "restored", ForwardPorts: &emptyPorts}},
|
||||
}
|
||||
imported := make(chan error, 1)
|
||||
runImport := func() {
|
||||
result, err := r.Import(backup, []string{"users", "forwards"})
|
||||
if err == nil && (result.UsersImported != 1 || result.ForwardsImported != 1) {
|
||||
err = fmt.Errorf("wrong retry count: %+v", result)
|
||||
}
|
||||
imported <- err
|
||||
}
|
||||
go runImport()
|
||||
if mode == "busy-bounded" {
|
||||
select {
|
||||
case err := <-imported:
|
||||
if err == nil || !strings.Contains(err.Error(), "database is busy") {
|
||||
t.Fatalf("expected bounded busy error, got %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = barrier.Rollback()
|
||||
t.Fatal("import waited while holding partial table locks")
|
||||
}
|
||||
} else {
|
||||
// The held child lock makes every attempt fail before writes.
|
||||
// Let several bounded attempts encounter it before release.
|
||||
time.Sleep(75 * time.Millisecond)
|
||||
}
|
||||
if err := barrier.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-deleted:
|
||||
if err != nil {
|
||||
t.Fatalf("cascade: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("cascade deadlocked")
|
||||
}
|
||||
if mode == "busy-bounded" {
|
||||
go runImport()
|
||||
}
|
||||
select {
|
||||
case err := <-imported:
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("import deadlocked")
|
||||
}
|
||||
if id, err := r.CreateUser("after", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id <= 100 {
|
||||
t.Fatalf("create after concurrent cascade: id=%d err=%v", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportRollsBackEarlierSequenceWhenLaterSequenceFails(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq MAXVALUE 10`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup := &model.BackupData{
|
||||
Users: []model.UserBackup{{ID: 100, User: "rollback-user", Pwd: "$2-test-fixture", RoleID: 1}},
|
||||
SpeedLimits: []model.SpeedLimitBackup{{ID: 10, Name: "exhausted"}},
|
||||
}
|
||||
// User sequence advances first. The exhausted limit sequence then fails.
|
||||
if _, err := r.Import(backup, []string{"users", "speedLimits"}); err == nil || !strings.Contains(err.Error(), "sequence exhausted") {
|
||||
t.Fatalf("expected sequence exhaustion: %v", err)
|
||||
}
|
||||
if id, err := r.CreateUser("after-failure", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != 2 {
|
||||
t.Fatalf("earlier sequence failed to roll back: id=%d err=%v", id, err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("after-failure", 100, 0, 1); err != nil || id != 1 {
|
||||
t.Fatalf("later sequence changed: id=%d err=%v", id, err)
|
||||
}
|
||||
got, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Users) != 2 || len(got.SpeedLimits) != 1 {
|
||||
t.Fatalf("partial import committed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportAPIAllowsImmediateCreate(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
const secret = "backup-sequence-contract"
|
||||
router := httpserver.NewRouter(handler.New(r, secret), secret)
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := func(path, body string) {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
request.Header.Set("Authorization", token)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
assertCode(t, response, 0)
|
||||
}
|
||||
post("/api/v1/backup/import", `{"version":"1.0","types":["users","speedLimits"],"users":[{"id":2,"user":"restored","pwd":"$2-test-fixture","roleId":1,"status":1}],"speedLimits":[{"id":1,"name":"restored","speed":100,"status":1}]}`)
|
||||
post("/api/v1/user/create", `{"user":"after-import","pwd":"test-only-password","status":1}`)
|
||||
post("/api/v1/speed-limit/create", `{"name":"after-import","speed":200}`)
|
||||
got, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Users) != 3 || got.Users[2].ID != 3 || len(got.SpeedLimits) != 2 || got.SpeedLimits[1].ID != 2 {
|
||||
t.Fatalf("API create did not retain restored records: %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user