New stored model (legacy columns untouched, read paths unchanged until B2): - inbounds.singbox_config (converted sing-box inbound/endpoint fragment JSON; empty = blocked or external-runtime) and inbounds.singbox_preserved (unmapped items verbatim + panel-side material + notes) - agent_nodes.singbox_outbounds / singbox_routing_rules Converter (internal/singbox, pure functions, ITEM6-CONVERSION-SPEC): all panel protocols mapped; unmapped constructs are preserved and registered, never dropped. B0 golden fixtures: 26/26 semantic match (fragment + unmapped concept/pending sets + disposition). One-shot migrator (internal/database, InitDB hook after initModels): marker-gated and idempotent; SQLite file backup before any write (failure refuses migration/startup); stats switches migrate out of the template BEFORE the xrayTemplateConfig row is discarded (SCOPE 4.3.1 order); identity layer is the only client truth with spec 7-1 mirror reconciliation (adopt/fill/conflict all reported); fallbacks merged from table + embedded settings, deduped; agent overrides converted with per-entry issues; everything in one transaction, marker written last; JSON report written next to the DB. Malformed rows/templates are hard failures (rollback, old DB untouched). settings.trafficStatsSwitches added with factory defaults (inbound on, outbound off) in defaultValueMap. Validation: synthetic agent-era DB (mirror drift, fallbacks, xhttp, agent geo rule, non-default stats) converts as specified; old production backup (25 inbounds) migrates with credentials, Reality pbk derivation, traffic counters and reset cycles byte-identical; idempotent re-run is a no-op; touched packages show zero new test failures vs the 9-red baseline.
577 lines
17 KiB
Go
577 lines
17 KiB
Go
package database
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/curve25519"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// TestSingboxMigration_OldBackupValidation runs the real migration over
|
|
// a copy of a production-era backup database and verifies, field by
|
|
// field, that nothing the panel depends on moved: identity credentials,
|
|
// Reality key material (stored private key → derived pbk equals the
|
|
// stored client-side public key), traffic counters and reset cycles,
|
|
// and every legacy column byte-for-byte.
|
|
//
|
|
// Gated on XUI_B1_OLD_BACKUP pointing at the backup .db file so normal
|
|
// test runs skip it.
|
|
func TestSingboxMigration_OldBackupValidation(t *testing.T) {
|
|
src := os.Getenv("XUI_B1_OLD_BACKUP")
|
|
if src == "" {
|
|
t.Skip("XUI_B1_OLD_BACKUP not set")
|
|
}
|
|
dir := t.TempDir()
|
|
dst := filepath.Join(dir, "backup.db")
|
|
copyFile(t, src, dst)
|
|
for _, suffix := range []string{"-wal", "-shm"} {
|
|
if _, err := os.Stat(src + suffix); err == nil {
|
|
copyFile(t, src+suffix, dst+suffix)
|
|
}
|
|
}
|
|
|
|
pre := dumpAllTables(t, dst)
|
|
preIdentity := identityByInbound(t, dst)
|
|
preMirror := mirrorUUIDsByInbound(t, dst)
|
|
preTemplateStats := templateStats(t, dst)
|
|
preSettings := settingsMap(t, dst)
|
|
|
|
if err := InitDB(dst); err != nil {
|
|
t.Fatalf("InitDB over backup copy: %v", err)
|
|
}
|
|
if err := CloseDB(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
|
|
post := dumpAllTables(t, dst)
|
|
postSettings := settingsMap(t, dst)
|
|
|
|
// 1. Legacy content identical apart from the sanctioned deltas:
|
|
// the new singbox_* columns on inbounds, the discarded template
|
|
// row, and the two new settings keys. Comparison projects each row
|
|
// onto the columns that existed pre-migration (InitDB's own legacy
|
|
// migrations may add columns), and the three JSON text columns of
|
|
// inbounds are compared semantically (legacy migrations may
|
|
// normalize their formatting without changing meaning).
|
|
assertTableUnchanged(t, pre, post, "clients")
|
|
assertTableUnchanged(t, pre, post, "client_inbounds")
|
|
assertTableUnchanged(t, pre, post, "client_traffics")
|
|
assertInboundsUnchanged(t, pre, post)
|
|
assertSettingsDelta(t, preSettings, postSettings)
|
|
|
|
// 2. Converted fragments carry exactly the identity layer.
|
|
gdb := openRaw(t, dst)
|
|
var rows []struct {
|
|
Id int
|
|
Protocol string
|
|
SingboxConfig string
|
|
SingboxPreserved string
|
|
StreamSettings string
|
|
Settings string
|
|
}
|
|
if err := gdb.Table("inbounds").
|
|
Select("id, protocol, singbox_config, singbox_preserved, stream_settings, settings").
|
|
Order("id ASC").Scan(&rows).Error; err != nil {
|
|
t.Fatalf("scan converted inbounds: %v", err)
|
|
}
|
|
if len(rows) != 25 {
|
|
t.Fatalf("inbounds = %d, want 25", len(rows))
|
|
}
|
|
blocked := 0
|
|
for _, r := range rows {
|
|
if r.SingboxConfig == "" {
|
|
blocked++
|
|
t.Errorf("inbound %d (%s) unexpectedly blocked: preserved=%s", r.Id, r.Protocol, r.SingboxPreserved)
|
|
continue
|
|
}
|
|
var frag map[string]any
|
|
if err := json.Unmarshal([]byte(r.SingboxConfig), &frag); err != nil {
|
|
t.Fatalf("inbound %d fragment not JSON: %v", r.Id, err)
|
|
}
|
|
want := preIdentity[r.Id]
|
|
switch r.Protocol {
|
|
case "vless":
|
|
got := map[string]bool{}
|
|
for _, u := range fragUsers(frag) {
|
|
got[u["uuid"]] = true
|
|
if u["uuid"] == "" {
|
|
t.Errorf("inbound %d: vless user with empty uuid: %v", r.Id, u)
|
|
}
|
|
}
|
|
// Every identity uuid must be projected; extras are only
|
|
// legitimate as spec §7-1 mirror adoptions.
|
|
for uuid := range want {
|
|
if !got[uuid] {
|
|
t.Errorf("inbound %d: identity uuid %s missing from fragment", r.Id, uuid)
|
|
}
|
|
}
|
|
for uuid := range got {
|
|
if _, inIdentity := want[uuid]; !inIdentity && !preMirror[r.Id][uuid] {
|
|
t.Errorf("inbound %d: fragment uuid %s in neither identity nor mirror", r.Id, uuid)
|
|
}
|
|
}
|
|
checkReality(t, r.Id, r.StreamSettings, frag)
|
|
case "shadowsocks":
|
|
for _, u := range fragUsers(frag) {
|
|
wantPW, ok := want[u["name"]]
|
|
if !ok {
|
|
t.Errorf("inbound %d: ss user %q not in identity", r.Id, u["name"])
|
|
} else if u["password"] != wantPW {
|
|
t.Errorf("inbound %d: ss user %q password mismatch", r.Id, u["name"])
|
|
}
|
|
}
|
|
if len(fragUsers(frag)) != len(want) {
|
|
t.Errorf("inbound %d: ss users %d != identity %d", r.Id, len(fragUsers(frag)), len(want))
|
|
}
|
|
case "mixed":
|
|
for _, u := range fragUsers(frag) {
|
|
if wantPW, ok := want[u["username"]]; ok && u["password"] != wantPW {
|
|
t.Errorf("inbound %d: mixed user %q password mismatch", r.Id, u["username"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if blocked != 0 {
|
|
t.Fatalf("blocked inbounds = %d, want 0 (production profile is all direct)", blocked)
|
|
}
|
|
|
|
// 3. Stats switches equal the pre-migration template's values.
|
|
if v, ok := getSettingRaw(t, gdb, trafficStatsSwitchesKey); !ok {
|
|
t.Fatalf("trafficStatsSwitches missing after migration")
|
|
} else {
|
|
var got trafficStatsSwitches
|
|
if err := json.Unmarshal([]byte(v), &got); err != nil {
|
|
t.Fatalf("parse stats: %v", err)
|
|
}
|
|
if preTemplateStats != nil && got != *preTemplateStats {
|
|
t.Fatalf("stats = %+v, want template's %+v", got, *preTemplateStats)
|
|
}
|
|
}
|
|
if _, ok := getSettingRaw(t, gdb, xrayTemplateSettingKey); ok {
|
|
t.Fatalf("template row should be discarded")
|
|
}
|
|
if v, ok := getSettingRaw(t, gdb, singboxMigrationVersionKey); !ok || v != "1" {
|
|
t.Fatalf("marker = %q, %v", v, ok)
|
|
}
|
|
|
|
// 4. Backup + report artifacts exist.
|
|
baks, _ := filepath.Glob(dst + ".pre-singbox-*.bak")
|
|
if len(baks) != 1 {
|
|
t.Fatalf("backup files = %v, want 1", baks)
|
|
}
|
|
reports, _ := filepath.Glob(dst + ".singbox-migration-*.json")
|
|
if len(reports) != 1 {
|
|
t.Fatalf("report files = %v, want 1", reports)
|
|
}
|
|
repData, _ := os.ReadFile(reports[0])
|
|
var rep singboxMigrationReport
|
|
if err := json.Unmarshal(repData, &rep); err != nil {
|
|
t.Fatalf("parse report: %v", err)
|
|
}
|
|
t.Logf("report: inbounds=%d fragments=%d endpoints=%d clients=%d dispositions=%v discrepancies=%d unmapped=%d notes=%d statsSource=%s templateNonDefault=%v",
|
|
rep.InboundsTotal, rep.FragmentsWritten, rep.EndpointsWritten, rep.ClientsProjected,
|
|
rep.Dispositions, len(rep.Discrepancies), len(rep.Unmapped), len(rep.Notes),
|
|
rep.StatsSource, rep.TemplateNonDefault)
|
|
for _, d := range rep.Discrepancies {
|
|
t.Logf("discrepancy: inbound=%d email=%s kind=%s field=%s", d.InboundID, d.Email, d.Kind, d.Field)
|
|
}
|
|
for _, u := range rep.Unmapped {
|
|
t.Logf("unmapped: inbound=%d concept=%s pending=%s material=%v", u.InboundID, u.Concept, u.Pending, u.Material)
|
|
}
|
|
}
|
|
|
|
// checkReality verifies the fragment's private_key matches the stored
|
|
// streamSettings and that its derived public key equals the stored
|
|
// client-side publicKey (the pbk subscribers already use).
|
|
func checkReality(t *testing.T, inboundID int, streamSettings string, frag map[string]any) {
|
|
t.Helper()
|
|
var ss map[string]any
|
|
if err := json.Unmarshal([]byte(streamSettings), &ss); err != nil {
|
|
t.Fatalf("inbound %d stream: %v", inboundID, err)
|
|
}
|
|
rs, _ := ss["realitySettings"].(map[string]any)
|
|
if rs == nil {
|
|
return // not a reality inbound
|
|
}
|
|
privStored, _ := rs["privateKey"].(string)
|
|
tlsBlk, _ := frag["tls"].(map[string]any)
|
|
reality, _ := tlsBlk["reality"].(map[string]any)
|
|
if reality == nil {
|
|
t.Errorf("inbound %d: reality block missing from fragment", inboundID)
|
|
return
|
|
}
|
|
if reality["private_key"] != privStored {
|
|
t.Errorf("inbound %d: fragment private_key != stored privateKey", inboundID)
|
|
}
|
|
settings, _ := rs["settings"].(map[string]any)
|
|
storedPub, _ := settings["publicKey"].(string)
|
|
if storedPub == "" || privStored == "" {
|
|
return
|
|
}
|
|
privBytes, err := decodeB64(privStored)
|
|
if err != nil || len(privBytes) != 32 {
|
|
t.Errorf("inbound %d: stored privateKey not a 32-byte base64 key", inboundID)
|
|
return
|
|
}
|
|
pubBytes, err := decodeB64(storedPub)
|
|
if err != nil || len(pubBytes) != 32 {
|
|
t.Errorf("inbound %d: stored publicKey not a 32-byte base64 key", inboundID)
|
|
return
|
|
}
|
|
var priv, pub [32]byte
|
|
copy(priv[:], privBytes)
|
|
curve25519.ScalarBaseMult(&pub, &priv)
|
|
if string(pub[:]) != string(pubBytes) {
|
|
t.Errorf("inbound %d: derived pbk does not match stored publicKey", inboundID)
|
|
}
|
|
}
|
|
|
|
// decodeB64 accepts the base64 flavours xray uses for Reality keys
|
|
// (raw URL encoding without padding, or padded standard/url).
|
|
func decodeB64(s string) ([]byte, error) {
|
|
for _, enc := range []*base64.Encoding{
|
|
base64.RawURLEncoding, base64.URLEncoding,
|
|
base64.StdEncoding, base64.RawStdEncoding,
|
|
} {
|
|
if b, err := enc.DecodeString(s); err == nil {
|
|
return b, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("not base64")
|
|
}
|
|
|
|
func fragUsers(frag map[string]any) []map[string]string {
|
|
out := []map[string]string{}
|
|
if arr, ok := frag["users"].([]any); ok {
|
|
for _, e := range arr {
|
|
m, _ := e.(map[string]any)
|
|
u := map[string]string{}
|
|
for k, v := range m {
|
|
if s, ok := v.(string); ok {
|
|
u[k] = s
|
|
}
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func keys(m map[string]bool) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// identityByInbound computes, from the PRE-migration copy, each
|
|
// inbound's identity credentials (email → uuid for vless/vmess,
|
|
// email → password elsewhere) using ListForInbound's join semantics.
|
|
func identityByInbound(t *testing.T, path string) map[int]map[string]string {
|
|
t.Helper()
|
|
gdb := openRaw(t, path)
|
|
type row struct {
|
|
InboundId int
|
|
Email string
|
|
UUID string
|
|
Password string
|
|
Protocol string
|
|
}
|
|
var rows []row
|
|
err := gdb.Table("clients").
|
|
Select("client_inbounds.inbound_id AS inbound_id, clients.email AS email, clients.uuid AS uuid, clients.password AS password, '' AS protocol").
|
|
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
t.Fatalf("identity query: %v", err)
|
|
}
|
|
protoByInbound := map[int]string{}
|
|
var ibs []struct {
|
|
Id int
|
|
Protocol string
|
|
}
|
|
if err := gdb.Table("inbounds").Select("id, protocol").Scan(&ibs).Error; err != nil {
|
|
t.Fatalf("inbound protocols: %v", err)
|
|
}
|
|
for _, ib := range ibs {
|
|
protoByInbound[ib.Id] = ib.Protocol
|
|
}
|
|
out := map[int]map[string]string{}
|
|
for _, r := range rows {
|
|
if out[r.InboundId] == nil {
|
|
out[r.InboundId] = map[string]string{}
|
|
}
|
|
switch protoByInbound[r.InboundId] {
|
|
case "vless", "vmess":
|
|
out[r.InboundId][r.UUID] = r.Email
|
|
default:
|
|
out[r.InboundId][r.Email] = r.Password
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func templateStats(t *testing.T, path string) *trafficStatsSwitches {
|
|
t.Helper()
|
|
gdb := openRaw(t, path)
|
|
v, ok := getSettingRaw(t, gdb, xrayTemplateSettingKey)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var tpl map[string]any
|
|
if err := json.Unmarshal([]byte(v), &tpl); err != nil {
|
|
t.Fatalf("pre template parse: %v", err)
|
|
}
|
|
stats := defaultTrafficStatsSwitches()
|
|
if policy, ok := tpl["policy"].(map[string]any); ok {
|
|
if sys, ok := policy["system"].(map[string]any); ok {
|
|
if b, ok := sys["statsInboundUplink"].(bool); ok {
|
|
stats.InboundUplink = b
|
|
}
|
|
if b, ok := sys["statsInboundDownlink"].(bool); ok {
|
|
stats.InboundDownlink = b
|
|
}
|
|
if b, ok := sys["statsOutboundUplink"].(bool); ok {
|
|
stats.OutboundUplink = b
|
|
}
|
|
if b, ok := sys["statsOutboundDownlink"].(bool); ok {
|
|
stats.OutboundDownlink = b
|
|
}
|
|
}
|
|
}
|
|
return &stats
|
|
}
|
|
|
|
// dumpAllTables returns every table's rows as stringified key/value
|
|
// maps (map-based, so it works against pre-migration schemas lacking
|
|
// the new columns).
|
|
func dumpAllTables(t *testing.T, path string) map[string][]map[string]string {
|
|
t.Helper()
|
|
gdb, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("dump open: %v", err)
|
|
}
|
|
defer func() {
|
|
if sqlDB, err := gdb.DB(); err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
}()
|
|
var tables []string
|
|
if err := gdb.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").Scan(&tables).Error; err != nil {
|
|
t.Fatalf("list tables: %v", err)
|
|
}
|
|
out := map[string][]map[string]string{}
|
|
for _, tbl := range tables {
|
|
var rows []map[string]any
|
|
if err := gdb.Table(tbl).Find(&rows).Error; err != nil {
|
|
t.Fatalf("dump %s: %v", tbl, err)
|
|
}
|
|
rendered := make([]map[string]string, 0, len(rows))
|
|
for _, r := range rows {
|
|
m := map[string]string{}
|
|
for k, v := range r {
|
|
m[k] = fmt.Sprintf("%v", v)
|
|
}
|
|
rendered = append(rendered, m)
|
|
}
|
|
out[tbl] = rendered
|
|
}
|
|
return out
|
|
}
|
|
|
|
// rowsByID indexes rows by their id column.
|
|
func rowsByID(rows []map[string]string) map[string]map[string]string {
|
|
out := map[string]map[string]string{}
|
|
for _, r := range rows {
|
|
out[r["id"]] = r
|
|
}
|
|
return out
|
|
}
|
|
|
|
// renderRow renders the given columns of a row canonically.
|
|
func renderRow(r map[string]string, cols []string) string {
|
|
var sb strings.Builder
|
|
for _, c := range cols {
|
|
fmt.Fprintf(&sb, "%s=%s;", c, r[c])
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// assertTableUnchanged requires the pre-migration rows to survive as
|
|
// a multiset with identical values on every column that existed
|
|
// pre-migration (post rows may carry additional, newly-added
|
|
// columns). Works for composite-key tables too.
|
|
func assertTableUnchanged(t *testing.T, pre, post map[string][]map[string]string, table string) {
|
|
t.Helper()
|
|
preRows, ok := pre[table]
|
|
if !ok {
|
|
return // table did not exist pre-migration (e.g. agent_nodes)
|
|
}
|
|
if len(post[table]) != len(preRows) {
|
|
t.Fatalf("table %s row count changed: %d -> %d", table, len(preRows), len(post[table]))
|
|
}
|
|
colSet := map[string]bool{}
|
|
for _, r := range preRows {
|
|
for k := range r {
|
|
colSet[k] = true
|
|
}
|
|
}
|
|
cols := make([]string, 0, len(colSet))
|
|
for k := range colSet {
|
|
cols = append(cols, k)
|
|
}
|
|
sort.Strings(cols)
|
|
project := func(rows []map[string]string) []string {
|
|
out := make([]string, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, renderRow(r, cols))
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
preP, postP := project(preRows), project(post[table])
|
|
for i := range preP {
|
|
if preP[i] != postP[i] {
|
|
t.Fatalf("table %s row changed:\npre: %s\npost: %s", table, preP[i], postP[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// assertInboundsUnchanged is assertTableUnchanged for inbounds, with
|
|
// the three legacy JSON text columns compared semantically (other
|
|
// InitDB migrations may reformat them) and the new singbox_* columns
|
|
// excluded.
|
|
func assertInboundsUnchanged(t *testing.T, pre, post map[string][]map[string]string) {
|
|
t.Helper()
|
|
preRows := pre["inbounds"]
|
|
postByID := rowsByID(post["inbounds"])
|
|
if len(postByID) != len(preRows) {
|
|
t.Fatalf("inbounds row count changed: %d -> %d", len(preRows), len(postByID))
|
|
}
|
|
jsonCols := map[string]bool{"settings": true, "stream_settings": true, "sniffing": true}
|
|
for _, pr := range preRows {
|
|
qr, ok := postByID[pr["id"]]
|
|
if !ok {
|
|
t.Fatalf("inbound id=%s vanished", pr["id"])
|
|
}
|
|
for k, v := range pr {
|
|
if k == "singbox_config" || k == "singbox_preserved" {
|
|
continue
|
|
}
|
|
if jsonCols[k] {
|
|
if !jsonSemanticallyEqual(v, qr[k]) {
|
|
t.Fatalf("inbound id=%s column %s changed semantically:\npre: %s\npost: %s", pr["id"], k, v, qr[k])
|
|
}
|
|
continue
|
|
}
|
|
if qr[k] != v {
|
|
t.Fatalf("inbound id=%s column %s changed: %q -> %q", pr["id"], k, v, qr[k])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func jsonSemanticallyEqual(a, b string) bool {
|
|
if a == b {
|
|
return true
|
|
}
|
|
var av, bv any
|
|
if err := json.Unmarshal([]byte(a), &av); err != nil {
|
|
return false
|
|
}
|
|
if err := json.Unmarshal([]byte(b), &bv); err != nil {
|
|
return false
|
|
}
|
|
ab, _ := json.Marshal(av)
|
|
bb, _ := json.Marshal(bv)
|
|
return string(ab) == string(bb)
|
|
}
|
|
|
|
func settingsMap(t *testing.T, path string) map[string]string {
|
|
t.Helper()
|
|
gdb := openRaw(t, path)
|
|
var rows []struct {
|
|
Key string
|
|
Value string
|
|
}
|
|
if err := gdb.Table("settings").Select("key, value").Scan(&rows).Error; err != nil {
|
|
t.Fatalf("settings scan: %v", err)
|
|
}
|
|
out := map[string]string{}
|
|
for _, r := range rows {
|
|
out[r.Key] = r.Value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mirrorUUIDsByInbound(t *testing.T, path string) map[int]map[string]bool {
|
|
t.Helper()
|
|
gdb := openRaw(t, path)
|
|
var rows []struct {
|
|
Id int
|
|
Settings string
|
|
}
|
|
if err := gdb.Table("inbounds").Select("id, settings").Scan(&rows).Error; err != nil {
|
|
t.Fatalf("mirror scan: %v", err)
|
|
}
|
|
out := map[int]map[string]bool{}
|
|
for _, r := range rows {
|
|
set := map[string]bool{}
|
|
var wrap struct {
|
|
Clients []struct {
|
|
ID string `json:"id"`
|
|
} `json:"clients"`
|
|
}
|
|
if err := json.Unmarshal([]byte(r.Settings), &wrap); err == nil {
|
|
for _, c := range wrap.Clients {
|
|
if c.ID != "" {
|
|
set[c.ID] = true
|
|
}
|
|
}
|
|
}
|
|
out[r.Id] = set
|
|
}
|
|
return out
|
|
}
|
|
|
|
func assertSettingsDelta(t *testing.T, preM, postM map[string]string) {
|
|
t.Helper()
|
|
for k, v := range preM {
|
|
if k == xrayTemplateSettingKey {
|
|
continue
|
|
}
|
|
if postM[k] != v {
|
|
t.Fatalf("setting %q changed: %q -> %q", k, v, postM[k])
|
|
}
|
|
}
|
|
for k := range postM {
|
|
if _, ok := preM[k]; !ok && k != singboxMigrationVersionKey && k != trafficStatsSwitchesKey {
|
|
t.Fatalf("unexpected new setting %q", k)
|
|
}
|
|
}
|
|
if _, ok := postM[xrayTemplateSettingKey]; ok {
|
|
t.Fatalf("template row should be gone post-migration")
|
|
}
|
|
}
|
|
|
|
func copyFile(t *testing.T, src, dst string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(src)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", src, err)
|
|
}
|
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", dst, err)
|
|
}
|
|
}
|