refactor(backend): migrate to modular repository pattern with separated concerns

- Extract database layer into model and repo packages
- Split repository into focused modules (control, federation, flow, groups, mutations)
- Remove monolithic db.go and sqlite/repository.go
- Update handlers to use new repository structure
- Migrate contract tests to new patterns
- Add migration plan documentation
This commit is contained in:
Antigravity
2026-02-17 04:47:11 +00:00
parent 98b4d78b4d
commit 66be07750f
44 changed files with 6829 additions and 6188 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,308 @@
package repo
import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"gorm.io/gorm"
"go-backend/internal/store/model"
)
func (r *Repository) UserTunnelExistsByUserAndTunnel(userID, tunnelID int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.UserTunnel{}).
Where("user_id = ? AND tunnel_id = ? AND status = 1", userID, tunnelID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var forwards []model.Forward
err := r.db.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
rows := make([]model.ForwardRecord, 0, len(forwards))
for _, f := range forwards {
rows = append(rows, model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
})
}
for i := range rows {
if strings.TrimSpace(rows[i].Strategy) == "" {
rows[i].Strategy = "fifo"
}
}
return rows, nil
}
func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ports []model.ForwardPort
err := r.db.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error
if err != nil {
return nil, err
}
rows := make([]model.ForwardPortRecord, 0, len(ports))
for _, p := range ports {
rows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port})
}
return rows, nil
}
func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {
if r == nil || r.db == nil {
return "", errors.New("repository not initialized")
}
var ct model.ChainTunnel
err := r.db.Select("protocol").
Where("tunnel_id = ? AND chain_type = ?", tunnelID, "3").
Order("id ASC").
Take(&ct).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err
}
if ct.Protocol.Valid {
return ct.Protocol.String, nil
}
return "", nil
}
func (r *Repository) GetNodeRecord(nodeID int64) (*model.NodeRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var n model.Node
err := r.db.Where("id = ?", nodeID).First(&n).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return nodeRecordFromModel(&n), nil
}
func (r *Repository) GetNodeRecordTx(tx *gorm.DB, nodeID int64) (*model.NodeRecord, error) {
if tx == nil {
return nil, errors.New("database unavailable")
}
var n model.Node
err := tx.Where("id = ?", nodeID).First(&n).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return nodeRecordFromModel(&n), nil
}
func nodeRecordFromModel(n *model.Node) *model.NodeRecord {
if n == nil {
return nil
}
rec := &model.NodeRecord{
ID: n.ID,
Name: n.Name,
ServerIP: n.ServerIP,
Status: n.Status,
PortRange: n.Port,
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
IsRemote: n.IsRemote,
}
if n.ServerIPV4.Valid {
rec.ServerIPv4 = strings.TrimSpace(n.ServerIPV4.String)
}
if n.ServerIPV6.Valid {
rec.ServerIPv6 = strings.TrimSpace(n.ServerIPV6.String)
}
if n.InterfaceName.Valid {
rec.InterfaceName = strings.TrimSpace(n.InterfaceName.String)
}
if n.RemoteURL.Valid {
rec.RemoteURL = strings.TrimSpace(n.RemoteURL.String)
}
if n.RemoteToken.Valid {
rec.RemoteToken = strings.TrimSpace(n.RemoteToken.String)
}
if n.RemoteConfig.Valid {
rec.RemoteConfig = strings.TrimSpace(n.RemoteConfig.String)
}
if rec.TCPListenAddr == "" {
rec.TCPListenAddr = "[::]"
}
if rec.UDPListenAddr == "" {
rec.UDPListenAddr = "[::]"
}
if strings.TrimSpace(rec.Name) == "" {
rec.Name = fmt.Sprintf("node_%d", rec.ID)
}
return rec
}
func (r *Repository) ResolveUserTunnelAndLimiter(userID, tunnelID int64) (*model.UserTunnelLimiterInfo, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
type row struct {
UserTunnelID int64 `gorm:"column:user_tunnel_id"`
LimiterID sql.NullInt64 `gorm:"column:limiter_id"`
Speed sql.NullInt64 `gorm:"column:speed"`
}
var rec row
err := r.db.Model(&model.UserTunnel{}).
Select("user_tunnel.id AS user_tunnel_id, speed_limit.id AS limiter_id, speed_limit.speed AS speed").
Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id").
Where("user_tunnel.user_id = ? AND user_tunnel.tunnel_id = ?", userID, tunnelID).
Order("user_tunnel.id ASC").
Limit(1).
Take(&rec).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &model.UserTunnelLimiterInfo{}, nil
}
return nil, err
}
info := &model.UserTunnelLimiterInfo{UserTunnelID: rec.UserTunnelID}
if rec.LimiterID.Valid && rec.LimiterID.Int64 > 0 {
v := rec.LimiterID.Int64
info.LimiterID = &v
s := int(rec.Speed.Int64)
info.Speed = &s
}
return info, nil
}
func (r *Repository) ListUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.UserTunnel{}).
Where("user_id = ? AND tunnel_id = ?", userID, tunnelID).
Order("id ASC").Pluck("id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *Repository) ListUserTunnelIDsByUser(userID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.UserTunnel{}).
Where("user_id = ?", userID).
Order("id ASC").Pluck("id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *Repository) GetTunnelName(tunnelID int64) (string, error) {
if r == nil || r.db == nil {
return "", errors.New("repository not initialized")
}
var name string
err := r.db.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Pluck("name", &name).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err
}
return name, nil
}
func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
type row struct {
ChainType string
Inx sql.NullInt64
NodeID int64
Port sql.NullInt64
Name sql.NullString
Protocol sql.NullString
Strategy sql.NullString
}
var rows []row
err := r.db.Model(&model.ChainTunnel{}).
Select("chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy").
Joins("LEFT JOIN node ON node.id = chain_tunnel.node_id").
Where("chain_tunnel.tunnel_id = ?", tunnelID).
Order("chain_tunnel.chain_type ASC, chain_tunnel.inx ASC, chain_tunnel.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
result := make([]model.ChainNodeRecord, 0, len(rows))
for _, row := range rows {
chainType := 0
if v := strings.TrimSpace(row.ChainType); v != "" {
if parsed, parseErr := strconv.Atoi(v); parseErr == nil {
chainType = parsed
}
}
inx := int64(0)
if row.Inx.Valid {
inx = row.Inx.Int64
}
port := 0
if row.Port.Valid {
port = int(row.Port.Int64)
}
item := model.ChainNodeRecord{
ChainType: chainType,
Inx: inx,
NodeID: row.NodeID,
Port: port,
}
if strings.TrimSpace(row.Name.String) == "" {
item.NodeName = fmt.Sprintf("node_%d", row.NodeID)
} else {
item.NodeName = row.Name.String
}
if strings.TrimSpace(row.Protocol.String) == "" {
item.Protocol = "tls"
} else {
item.Protocol = row.Protocol.String
}
if strings.TrimSpace(row.Strategy.String) == "" {
item.Strategy = "round"
} else {
item.Strategy = row.Strategy.String
}
result = append(result, item)
}
return result, nil
}
@@ -0,0 +1,269 @@
package repo
import (
"database/sql"
"errors"
"go-backend/internal/store/model"
"gorm.io/gorm"
)
// RemoteNodeRow holds the columns fetched for a remote node listing.
type RemoteNodeRow struct {
ID int64
Name string
RemoteURL sql.NullString
RemoteToken sql.NullString
RemoteConfig sql.NullString
}
// NodeBasicInfo holds name, server_ip, and status for a node.
type NodeBasicInfo struct {
Name string
ServerIP string
Status int
}
// FederationBindingRow holds the columns for an active federation tunnel binding.
type FederationBindingRow struct {
ID int64
TunnelID int64
TunnelName string
ChainType int
HopInx int
AllocatedPort int
ResourceKey string
RemoteBindingID string
UpdatedTime int64
}
// ListRemoteNodes returns all nodes with is_remote=1, ordered by id desc.
func (r *Repository) ListRemoteNodes() ([]RemoteNodeRow, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var result []RemoteNodeRow
err := r.db.Model(&model.Node{}).
Select("id, name, remote_url, remote_token, remote_config").
Where("is_remote = 1").
Order("id DESC").
Find(&result).Error
if err != nil {
return nil, err
}
if result == nil {
result = make([]RemoteNodeRow, 0)
}
return result, nil
}
// UpdateNodeRemoteConfig sets the remote_config JSON for a given node.
func (r *Repository) UpdateNodeRemoteConfig(nodeID int64, configJSON string) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.Node{}).Where("id = ?", nodeID).Update("remote_config", configJSON).Error
}
// ListActiveBindingsForNode returns active federation tunnel bindings for a node.
func (r *Repository) ListActiveBindingsForNode(nodeID int64) ([]FederationBindingRow, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var result []FederationBindingRow
err := r.db.Model(&model.FederationTunnelBinding{}).
Select("federation_tunnel_binding.id, federation_tunnel_binding.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, federation_tunnel_binding.chain_type, federation_tunnel_binding.hop_inx, federation_tunnel_binding.allocated_port, federation_tunnel_binding.resource_key, federation_tunnel_binding.remote_binding_id, federation_tunnel_binding.updated_time").
Joins("LEFT JOIN tunnel ON tunnel.id = federation_tunnel_binding.tunnel_id").
Where("federation_tunnel_binding.node_id = ? AND federation_tunnel_binding.status = 1", nodeID).
Order("federation_tunnel_binding.allocated_port ASC, federation_tunnel_binding.id ASC").
Find(&result).Error
if err != nil {
return nil, err
}
if result == nil {
result = make([]FederationBindingRow, 0)
}
return result, nil
}
// GetNodeBasicInfo returns the name, server_ip, and status for a given node.
func (r *Repository) GetNodeBasicInfo(nodeID int64) (*NodeBasicInfo, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var n model.Node
err := r.db.Select("name", "server_ip", "status").Where("id = ?", nodeID).First(&n).Error
if err != nil {
return nil, err
}
return &NodeBasicInfo{Name: n.Name, ServerIP: n.ServerIP, Status: n.Status}, nil
}
// CreateFederationTunnel creates a tunnel and chain_tunnel entry in a transaction,
// returning the new tunnel ID.
func (r *Repository) CreateFederationTunnel(name string, tunnelType int, protocol string, now int64, nodeID int64, remotePort int) (int64, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
tunnel := model.Tunnel{
Name: name,
Type: tunnelType,
Protocol: protocol,
Flow: 0,
CreatedTime: now,
UpdatedTime: now,
Status: 1,
InIP: sql.NullString{String: "", Valid: false},
}
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&tunnel).Error; err != nil {
return err
}
ct := model.ChainTunnel{
TunnelID: tunnel.ID,
ChainType: "1",
NodeID: nodeID,
Port: sql.NullInt64{Int64: int64(remotePort), Valid: true},
Strategy: sql.NullString{String: "fifo", Valid: true},
Inx: sql.NullInt64{Int64: 0, Valid: true},
Protocol: sql.NullString{String: protocol, Valid: true},
}
if err := tx.Create(&ct).Error; err != nil {
return err
}
return nil
})
if err != nil {
return 0, err
}
return tunnel.ID, nil
}
// ListUsedPortsOnNode returns all ports in use on a given node from chain_tunnel and forward_port tables.
func (r *Repository) ListUsedPortsOnNode(nodeID int64) ([]int, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
used := make(map[int]struct{})
var chainPorts []int
err := r.db.Model(&model.ChainTunnel{}).
Where("node_id = ? AND port > 0", nodeID).
Pluck("port", &chainPorts).Error
if err != nil {
return nil, err
}
for _, p := range chainPorts {
if p > 0 {
used[p] = struct{}{}
}
}
var forwardPorts []int
err = r.db.Model(&model.ForwardPort{}).
Where("node_id = ? AND port > 0", nodeID).
Pluck("port", &forwardPorts).Error
if err != nil {
return nil, err
}
for _, p := range forwardPorts {
if p > 0 {
used[p] = struct{}{}
}
}
result := make([]int, 0, len(used))
for p := range used {
result = append(result, p)
}
return result, nil
}
// ListTunnelIDsByNamePrefix returns all tunnel IDs whose name starts with the given prefix.
func (r *Repository) ListTunnelIDsByNamePrefix(prefix string) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.Tunnel{}).
Where("name LIKE ?", prefix+"%").
Order("id ASC").
Pluck("id", &ids).Error
if err != nil {
return nil, err
}
if ids == nil {
ids = make([]int64, 0)
}
return ids, nil
}
// NextIndex returns COALESCE(MAX(inx), -1) + 1 for the given table.
func (r *Repository) NextIndex(table string) int {
if r == nil || r.db == nil {
return 0
}
var modelRef interface{}
switch table {
case "node":
modelRef = &model.Node{}
case "tunnel":
modelRef = &model.Tunnel{}
case "forward":
modelRef = &model.Forward{}
default:
return 0
}
type inxRow struct {
Inx int
}
var row inxRow
err := r.db.Model(modelRef).
Select("inx").
Order("inx DESC").
Limit(1).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0
}
if err != nil {
return 0
}
if row.Inx < 0 {
return 0
}
return row.Inx + 1
}
// CreateRemoteNode inserts a new remote node.
func (r *Repository) CreateRemoteNode(name, secret, serverIP, portRange string, now int64, status int, inx int, remoteURL, remoteToken, remoteConfigJSON string) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
node := model.Node{
Name: name,
Secret: secret,
ServerIP: serverIP,
ServerIPV4: sql.NullString{},
ServerIPV6: sql.NullString{},
Port: portRange,
InterfaceName: sql.NullString{},
Version: sql.NullString{},
HTTP: 0,
TLS: 0,
Socks: 0,
CreatedTime: now,
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: status,
TCPListenAddr: "[::]",
UDPListenAddr: "[::]",
Inx: inx,
IsRemote: 1,
RemoteURL: sql.NullString{String: remoteURL, Valid: remoteURL != ""},
RemoteToken: sql.NullString{String: remoteToken, Valid: remoteToken != ""},
RemoteConfig: sql.NullString{String: remoteConfigJSON, Valid: remoteConfigJSON != ""},
}
return r.db.Create(&node).Error
}
@@ -0,0 +1,171 @@
package repo
import (
"errors"
"strings"
"gorm.io/gorm"
"go-backend/internal/store/model"
)
func (r *Repository) UpdateForwardStatus(forwardID int64, status int, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.Forward{}).Where("id = ?", forwardID).Updates(map[string]interface{}{
"status": status, "updated_time": now,
}).Error
}
func (r *Repository) ListActiveForwardsByUser(userID int64) ([]model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var forwards []model.Forward
err := r.db.Where("user_id = ? AND status = 1", userID).Order("id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
rows := make([]model.ForwardRecord, 0, len(forwards))
for _, f := range forwards {
rows = append(rows, model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
})
}
for i := range rows {
if strings.TrimSpace(rows[i].Strategy) == "" {
rows[i].Strategy = "fifo"
}
}
return rows, nil
}
func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var forwards []model.Forward
err := r.db.Where("user_id = ? AND tunnel_id = ? AND status = 1", userID, tunnelID).Order("id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
rows := make([]model.ForwardRecord, 0, len(forwards))
for _, f := range forwards {
rows = append(rows, model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
})
}
for i := range rows {
if strings.TrimSpace(rows[i].Strategy) == "" {
rows[i].Strategy = "fifo"
}
}
return rows, nil
}
func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var f model.Forward
err := r.db.Where("id = ?", forwardID).First(&f).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
fr := model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
}
if strings.TrimSpace(fr.Strategy) == "" {
fr.Strategy = "fifo"
}
return &fr, nil
}
func (r *Repository) GetTunnelRecord(tunnelID int64) (*model.TunnelRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var t model.Tunnel
err := r.db.Where("id = ?", tunnelID).First(&t).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
tr := model.TunnelRecord{
ID: t.ID,
Type: t.Type,
Status: t.Status,
Flow: t.Flow,
TrafficRatio: t.TrafficRatio,
}
if tr.Flow <= 0 {
tr.Flow = 1
}
if tr.TrafficRatio <= 0 {
tr.TrafficRatio = 1
}
return &tr, nil
}
func (r *Repository) TunnelExists(tunnelID int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) ForwardExists(forwardID int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.Forward{}).Where("id = ?", forwardID).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.SpeedLimit{}).Where("id = ?", id).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
@@ -0,0 +1,69 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
)
// ─── Semantic Group Queries (replacing QueryInt64List/QueryPairs passthrough) ─
// ListUserIDsByUserGroup returns all user IDs belonging to a user group.
func (r *Repository) ListUserIDsByUserGroup(userGroupID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.UserGroupUser{}).
Where("user_group_id = ?", userGroupID).
Pluck("user_id", &ids).Error
return ids, err
}
// ListTunnelIDsByTunnelGroup returns all tunnel IDs belonging to a tunnel group.
func (r *Repository) ListTunnelIDsByTunnelGroup(tunnelGroupID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.TunnelGroupTunnel{}).
Where("tunnel_group_id = ?", tunnelGroupID).
Pluck("tunnel_id", &ids).Error
return ids, err
}
// ListGroupPermissionPairsByUserGroup returns [userGroupID, tunnelGroupID] pairs
// for all group permissions associated with a user group.
func (r *Repository) ListGroupPermissionPairsByUserGroup(userGroupID int64) ([][2]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var perms []model.GroupPermission
err := r.db.Where("user_group_id = ?", userGroupID).Find(&perms).Error
if err != nil {
return nil, err
}
result := make([][2]int64, len(perms))
for i, p := range perms {
result[i] = [2]int64{p.UserGroupID, p.TunnelGroupID}
}
return result, err
}
// ListGroupPermissionPairsByTunnelGroup returns [userGroupID, tunnelGroupID] pairs
// for all group permissions associated with a tunnel group.
func (r *Repository) ListGroupPermissionPairsByTunnelGroup(tunnelGroupID int64) ([][2]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var perms []model.GroupPermission
err := r.db.Where("tunnel_group_id = ?", tunnelGroupID).Find(&perms).Error
if err != nil {
return nil, err
}
result := make([][2]int64, len(perms))
for i, p := range perms {
result[i] = [2]int64{p.UserGroupID, p.TunnelGroupID}
}
return result, err
}
@@ -0,0 +1,85 @@
package repo
import (
"errors"
"testing"
gsqlite "github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, currentSchemaVersion).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
called := 0
original := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
called++
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = original
})
if err := migrateSchema(db); err != nil {
t.Fatalf("migrateSchema: %v", err)
}
if called != 1 {
t.Fatalf("expected postgres id repair to run once, got %d", called)
}
}
func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, currentSchemaVersion).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
wantErr := errors.New("repair failed")
original := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return wantErr
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = original
})
err = migrateSchema(db)
if !errors.Is(err, wantErr) {
t.Fatalf("expected error %v, got %v", wantErr, err)
}
}
File diff suppressed because it is too large Load Diff