diff --git a/go-backend/internal/store/sqlite/repository.go b/go-backend/internal/store/sqlite/repository.go index 7c14603..9ecf3f2 100644 --- a/go-backend/internal/store/sqlite/repository.go +++ b/go-backend/internal/store/sqlite/repository.go @@ -2330,13 +2330,25 @@ func (r *Repository) exportTunnels() ([]TunnelBackup, error) { var tunnels []TunnelBackup for rows.Next() { var t TunnelBackup + var protocol sql.NullString + var updatedTime sql.NullInt64 var inIP sql.NullString - if err := rows.Scan(&t.ID, &t.Name, &t.TrafficRatio, &t.Type, &t.Protocol, &t.Flow, &t.CreatedTime, &t.UpdatedTime, &t.Status, &inIP, &t.Inx); err != nil { + var inx sql.NullInt64 + if err := rows.Scan(&t.ID, &t.Name, &t.TrafficRatio, &t.Type, &protocol, &t.Flow, &t.CreatedTime, &updatedTime, &t.Status, &inIP, &inx); err != nil { return nil, err } + if protocol.Valid { + t.Protocol = protocol.String + } + if updatedTime.Valid { + t.UpdatedTime = updatedTime.Int64 + } if inIP.Valid { t.InIP = inIP.String } + if inx.Valid { + t.Inx = int(inx.Int64) + } // Export chain tunnels chainTunnels, err := r.exportChainTunnels(t.ID) if err != nil { @@ -2362,12 +2374,23 @@ func (r *Repository) exportChainTunnels(tunnelID int64) ([]ChainTunnelBackup, er for rows.Next() { var ct ChainTunnelBackup var port sql.NullInt64 - if err := rows.Scan(&ct.ID, &ct.TunnelID, &ct.ChainType, &ct.NodeID, &port, &ct.Strategy, &ct.Inx, &ct.Protocol); err != nil { + var strategy, protocol sql.NullString + var inx sql.NullInt64 + if err := rows.Scan(&ct.ID, &ct.TunnelID, &ct.ChainType, &ct.NodeID, &port, &strategy, &inx, &protocol); err != nil { return nil, err } if port.Valid { ct.Port = int(port.Int64) } + if strategy.Valid { + ct.Strategy = strategy.String + } + if inx.Valid { + ct.Inx = int(inx.Int64) + } + if protocol.Valid { + ct.Protocol = protocol.String + } chainTunnels = append(chainTunnels, ct) } return chainTunnels, rows.Err() @@ -2386,9 +2409,21 @@ func (r *Repository) exportForwards() ([]ForwardBackup, error) { var forwards []ForwardBackup for rows.Next() { var f ForwardBackup - if err := rows.Scan(&f.ID, &f.UserID, &f.UserName, &f.Name, &f.TunnelID, &f.RemoteAddr, &f.Strategy, &f.InFlow, &f.OutFlow, &f.CreatedTime, &f.UpdatedTime, &f.Status, &f.Inx); err != nil { + var strategy sql.NullString + var updatedTime sql.NullInt64 + var inx sql.NullInt64 + if err := rows.Scan(&f.ID, &f.UserID, &f.UserName, &f.Name, &f.TunnelID, &f.RemoteAddr, &strategy, &f.InFlow, &f.OutFlow, &f.CreatedTime, &updatedTime, &f.Status, &inx); err != nil { return nil, err } + if strategy.Valid { + f.Strategy = strategy.String + } + if updatedTime.Valid { + f.UpdatedTime = updatedTime.Int64 + } + if inx.Valid { + f.Inx = int(inx.Int64) + } forwards = append(forwards, f) } return forwards, rows.Err() diff --git a/go-backend/tests/contract/migration_contract_test.go b/go-backend/tests/contract/migration_contract_test.go index 4bc80a3..7ff2bd9 100644 --- a/go-backend/tests/contract/migration_contract_test.go +++ b/go-backend/tests/contract/migration_contract_test.go @@ -310,6 +310,80 @@ func TestBackupExportImportRestoreContracts(t *testing.T) { t.Fatalf("expected restored config value v3, got %+v", cfg) } }) + + t.Run("backup export tolerates nullable legacy tunnel chain fields", func(t *testing.T) { + now := time.Now().UnixMilli() + res, err := repo.DB().Exec(` + INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "legacy-null-chain", 1.0, 1, "tls", 1000, now, now, 1, nil, 1) + if err != nil { + t.Fatalf("seed tunnel for nullable chain export: %v", err) + } + tunnelID, err := res.LastInsertId() + if err != nil { + t.Fatalf("read tunnel id for nullable chain export: %v", err) + } + + if _, err := repo.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) + VALUES(?, ?, ?, ?, ?, ?, ?) + `, tunnelID, "1", 1, nil, nil, nil, nil); err != nil { + t.Fatalf("seed nullable chain_tunnel row: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["tunnels"]}`)) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + + var payload struct { + Version string `json:"version"` + Tunnels []struct { + ID int64 `json:"id"` + ChainTunnels []struct { + Inx int `json:"inx"` + Strategy string `json:"strategy"` + Protocol string `json:"protocol"` + } `json:"chainTunnels"` + } `json:"tunnels"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode tunnels backup payload: %v", err) + } + if strings.TrimSpace(payload.Version) == "" { + t.Fatalf("expected backup payload version, got empty") + } + + found := false + for _, tunnel := range payload.Tunnels { + if tunnel.ID != tunnelID { + continue + } + if len(tunnel.ChainTunnels) != 1 { + t.Fatalf("expected one chain tunnel for seeded tunnel %d, got %d", tunnelID, len(tunnel.ChainTunnels)) + } + if tunnel.ChainTunnels[0].Inx != 0 { + t.Fatalf("expected nullable chain inx to export as 0, got %d", tunnel.ChainTunnels[0].Inx) + } + if tunnel.ChainTunnels[0].Strategy != "" { + t.Fatalf("expected nullable chain strategy to export as empty string, got %q", tunnel.ChainTunnels[0].Strategy) + } + if tunnel.ChainTunnels[0].Protocol != "" { + t.Fatalf("expected nullable chain protocol to export as empty string, got %q", tunnel.ChainTunnels[0].Protocol) + } + found = true + break + } + if !found { + t.Fatalf("expected seeded tunnel %d in backup export", tunnelID) + } + }) } type backupExportPayload struct {