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.
195 lines
5.1 KiB
Go
195 lines
5.1 KiB
Go
package singbox
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"testing"
|
|
)
|
|
|
|
// TestConvertGolden drives the B1 converter over the B0 golden fixtures
|
|
// (ITEM6-CONVERSION-SPEC). Fragments are compared by semantic JSON deep
|
|
// equality; unmapped items are compared as (concept, pending) sets —
|
|
// the fixture inputs are redacted (kcp seed="*", xmux trimmed), so the
|
|
// verbatim input payloads are not byte-comparable by design. Disposition
|
|
// must also match the B0 classification.
|
|
func TestConvertGolden(t *testing.T) {
|
|
root := filepath.Join("testdata", "golden")
|
|
dirs, err := os.ReadDir(root)
|
|
if err != nil {
|
|
t.Fatalf("read golden dir: %v", err)
|
|
}
|
|
ran := 0
|
|
for _, d := range dirs {
|
|
if !d.IsDir() {
|
|
continue
|
|
}
|
|
name := d.Name()
|
|
t.Run(name, func(t *testing.T) {
|
|
in := convertFixture(t, filepath.Join(root, name))
|
|
res, err := ConvertInbound(in)
|
|
if err != nil {
|
|
t.Fatalf("ConvertInbound: %v", err)
|
|
}
|
|
exp := loadExpected(t, filepath.Join(root, name, "expected-singbox.json"))
|
|
|
|
// Fragment: expected carries inbounds[] (+ endpoints[] for
|
|
// wireguard). Exactly one fragment object at most.
|
|
var gotFrag any
|
|
if res.Fragment != nil {
|
|
gotFrag = res.Fragment
|
|
}
|
|
var wantFrag any
|
|
key := "inbounds"
|
|
if res.Endpoint {
|
|
key = "endpoints"
|
|
}
|
|
if arr, ok := exp[key].([]any); ok && len(arr) > 0 {
|
|
wantFrag = arr[0]
|
|
}
|
|
if !reflect.DeepEqual(normalizeJSON(gotFrag), normalizeJSON(wantFrag)) {
|
|
t.Errorf("fragment mismatch:\n got: %s\nwant: %s",
|
|
marshalIndent(gotFrag), marshalIndent(wantFrag))
|
|
}
|
|
|
|
// Unmapped: (concept, pending) set equality.
|
|
gotSet := map[[2]string]int{}
|
|
for _, u := range res.Unmapped {
|
|
gotSet[[2]string{u.Concept, u.Pending}]++
|
|
}
|
|
wantSet := map[[2]string]int{}
|
|
if b0, ok := exp["_b0"].(map[string]any); ok {
|
|
if ums, ok := b0["unmapped"].([]any); ok {
|
|
for _, e := range ums {
|
|
em, _ := e.(map[string]any)
|
|
wantSet[[2]string{strOf(em["concept"]), strOf(em["pending"])}]++
|
|
}
|
|
}
|
|
if disp := strOf(b0["disposition"]); disp != "" && disp != res.Disposition {
|
|
t.Errorf("disposition = %q, want %q", res.Disposition, disp)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(gotSet, wantSet) {
|
|
t.Errorf("unmapped set mismatch:\n got: %v\nwant: %v",
|
|
sortedPairs(gotSet), sortedPairs(wantSet))
|
|
}
|
|
})
|
|
ran++
|
|
}
|
|
if ran != 26 {
|
|
t.Fatalf("golden cases ran = %d, want 26", ran)
|
|
}
|
|
}
|
|
|
|
func strOf(v any) string {
|
|
s, _ := v.(string)
|
|
return s
|
|
}
|
|
|
|
func sortedPairs(m map[[2]string]int) [][2]string {
|
|
out := make([][2]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i][0] != out[j][0] {
|
|
return out[i][0] < out[j][0]
|
|
}
|
|
return out[i][1] < out[j][1]
|
|
})
|
|
return out
|
|
}
|
|
|
|
func normalizeJSON(v any) any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return v
|
|
}
|
|
var out any
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
return v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func marshalIndent(v any) string {
|
|
b, _ := json.MarshalIndent(v, "", " ")
|
|
return string(b)
|
|
}
|
|
|
|
func loadExpected(t *testing.T, path string) map[string]any {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read expected: %v", err)
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
t.Fatalf("parse expected: %v", err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// convertFixture builds the converter input from a B0 fixture: the
|
|
// fixture's settings.clients mirror stands in for the identity layer
|
|
// (B0 built the fixtures from a state where mirror == identity), and
|
|
// settings.fallbacks supplies the fallback entries.
|
|
func convertFixture(t *testing.T, dir string) Inbound {
|
|
t.Helper()
|
|
data, err := os.ReadFile(filepath.Join(dir, "input.json"))
|
|
if err != nil {
|
|
t.Fatalf("read input: %v", err)
|
|
}
|
|
var root map[string]any
|
|
if err := json.Unmarshal(data, &root); err != nil {
|
|
t.Fatalf("parse input: %v", err)
|
|
}
|
|
ib, _ := root["inbound"].(map[string]any)
|
|
if ib == nil {
|
|
t.Fatalf("input has no inbound object")
|
|
}
|
|
settings := gObj(ib, "settings")
|
|
in := Inbound{
|
|
Listen: gStr(ib, "listen"),
|
|
Port: gInt(ib, "port"),
|
|
Protocol: gStr(ib, "protocol"),
|
|
Settings: rawOf(ib["settings"]),
|
|
StreamSettings: rawOf(ib["streamSettings"]),
|
|
Sniffing: rawOf(ib["sniffing"]),
|
|
}
|
|
for _, e := range gArr(settings, "fallbacks") {
|
|
in.Fallbacks = append(in.Fallbacks, rawOf(e))
|
|
}
|
|
for _, e := range gArr(settings, "clients") {
|
|
cm, _ := e.(map[string]any)
|
|
if cm == nil {
|
|
continue
|
|
}
|
|
cl := Client{
|
|
Email: gStr(cm, "email"),
|
|
UUID: gStr(cm, "id"),
|
|
Password: gStr(cm, "password"),
|
|
Auth: gStr(cm, "auth"),
|
|
Flow: gStr(cm, "flow"),
|
|
Security: gStr(cm, "security"),
|
|
AlterID: gInt(cm, "alterId"),
|
|
PublicKey: gStr(cm, "publicKey"),
|
|
PreSharedKey: gStr(cm, "preSharedKey"),
|
|
AllowedIPs: strArr(cm["allowedIPs"]),
|
|
KeepAlive: gInt(cm, "keepAlive"),
|
|
Secret: gStr(cm, "secret"),
|
|
AdTag: gStr(cm, "adTag"),
|
|
}
|
|
if rev, ok := cm["reverse"]; ok && rev != nil {
|
|
cl.Reverse = rawOf(rev)
|
|
}
|
|
in.Clients = append(in.Clients, cl)
|
|
}
|
|
return in
|
|
}
|