Files
3x-ui-agent/internal/sub/service_model_test.go
T
MuseandMuse Spark 741cd905ff ITEM6 B2: sing-box generation side (parallel path)
- SingboxService builds the full sing-box config from the new model:
  stored fragments re-projected against the identity layer (converter
  projections factored into internal/singbox/project.go, shared by
  converter and generator), agent singbox_outbounds/routing_rules
  override, forwarding compiled to sing-box outbounds+route rules,
  default private-CIDR reject rule (geoip:private replacement),
  tun dns landing in the config dns block (closes the B1 'B2' pending).
- trafficStatsSwitches wiring: experimental.v2ray_api.stats lists are
  generated per category only when both direction switches are on;
  all-off yields no experimental block (no backfill, per the switches'
  documented semantics).
- Agent /config endpoint additionally serves singbox_config +
  singbox_config_hash; xray payload untouched, sing-box build failures
  log-and-omit.
- SubService.GetLinkFromModel regenerates share links from identity +
  singbox_config/singbox_preserved only (legacy generators reused over
  rebuilt settings/stream views). Golden fixtures 26/26 byte-equal;
  production backup DB: 107/107 links equal (55 byte-equal, 52 equal
  modulo the legacy generator's own random Reality sni/sid pick),
  generated config passes sing-box check.
- Converter preservation amendments (panel-side, for blocked/discarded
  material link generation still needs): blockedTls for xhttp/kcp,
  verbatim streamNetwork/streamSecurity, vlessEncryption presence,
  ssPassword for removed ciphers, wireguardDns, socksAuth/socksAccounts
  provenance for exact mixed/socks user refresh.

Co-Authored-By: Muse Spark <noreply@muse.dev>
2026-10-22 21:42:40 +00:00

187 lines
5.2 KiB
Go

package sub
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/singbox"
)
// TestGoldenLinksFromModel is the B2 hard acceptance line for
// subscription links: for every B0 golden fixture, the link generated
// from the NEW model (identity clients + singbox_config /
// singbox_preserved, via GetLinkFromModel) must equal the B0 baseline
// byte for byte, and the legacy path over the same row must agree
// (proving the harness itself is faithful).
func TestGoldenLinksFromModel(t *testing.T) {
root := filepath.Join("..", "singbox", "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) {
dir := filepath.Join(root, name)
inbound, email := loadGoldenFixtureInbound(t, dir)
wantBytes, err := os.ReadFile(filepath.Join(dir, "expected-link.txt"))
if err != nil {
t.Fatalf("read expected-link: %v", err)
}
want := strings.TrimSuffix(string(wantBytes), "\n")
svc := NewSubService("")
if got := svc.GetLinkFromModel(inbound, email); got != want {
t.Errorf("GetLinkFromModel mismatch:\n got: %s\nwant: %s", got, want)
}
svcLegacy := NewSubService("")
if got := svcLegacy.GetLink(inbound, email); got != want {
t.Errorf("legacy GetLink mismatch (harness fault):\n got: %s\nwant: %s", got, want)
}
})
ran++
}
if ran != 26 {
t.Fatalf("golden cases ran = %d, want 26", ran)
}
}
// loadGoldenFixtureInbound builds the model.Inbound for one fixture
// the way a migrated production row looks: the legacy columns intact,
// plus singbox_config / singbox_preserved produced by running the B1
// converter over the fixture (the settings.clients mirror stands in
// for the identity layer, exactly as the converter's own golden test
// assembles it).
func loadGoldenFixtureInbound(t *testing.T, dir string) (*model.Inbound, string) {
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)
}
ibRaw, err := json.Marshal(root["inbound"])
if err != nil {
t.Fatalf("marshal inbound: %v", err)
}
var inbound model.Inbound
if err := json.Unmarshal(ibRaw, &inbound); err != nil {
t.Fatalf("unmarshal inbound: %v", err)
}
email, _ := root["clientEmail"].(string)
res, err := singbox.ConvertInbound(converterInputForFixture(t, root))
if err != nil {
t.Fatalf("ConvertInbound: %v", err)
}
frag, err := res.MarshalFragment()
if err != nil {
t.Fatalf("MarshalFragment: %v", err)
}
pres, err := res.MarshalPreserved()
if err != nil {
t.Fatalf("MarshalPreserved: %v", err)
}
inbound.SingboxConfig = frag
inbound.SingboxPreserved = pres
return &inbound, email
}
// converterInputForFixture assembles the converter input from a
// fixture the same way singbox's convertFixture does (kept local:
// test helpers are not exported across packages).
func converterInputForFixture(t *testing.T, root map[string]any) singbox.Inbound {
t.Helper()
ib, _ := root["inbound"].(map[string]any)
if ib == nil {
t.Fatalf("input has no inbound object")
}
obj := func(m map[string]any, key string) map[string]any {
if o, ok := m[key].(map[string]any); ok {
return o
}
return nil
}
str := func(m map[string]any, key string) string {
if s, ok := m[key].(string); ok {
return s
}
return ""
}
num := func(m map[string]any, key string) int {
if f, ok := m[key].(float64); ok {
return int(f)
}
return 0
}
arr := func(m map[string]any, key string) []any {
if a, ok := m[key].([]any); ok {
return a
}
return nil
}
raw := func(v any) json.RawMessage {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal raw: %v", err)
}
return b
}
settings := obj(ib, "settings")
in := singbox.Inbound{
Listen: str(ib, "listen"),
Port: num(ib, "port"),
Protocol: str(ib, "protocol"),
Settings: raw(ib["settings"]),
StreamSettings: raw(ib["streamSettings"]),
Sniffing: raw(ib["sniffing"]),
}
for _, e := range arr(settings, "fallbacks") {
in.Fallbacks = append(in.Fallbacks, raw(e))
}
for _, e := range arr(settings, "clients") {
cm, _ := e.(map[string]any)
if cm == nil {
continue
}
cl := singbox.Client{
Email: str(cm, "email"),
UUID: str(cm, "id"),
Password: str(cm, "password"),
Auth: str(cm, "auth"),
Flow: str(cm, "flow"),
Security: str(cm, "security"),
AlterID: num(cm, "alterId"),
PublicKey: str(cm, "publicKey"),
PreSharedKey: str(cm, "preSharedKey"),
KeepAlive: num(cm, "keepAlive"),
Secret: str(cm, "secret"),
AdTag: str(cm, "adTag"),
}
for _, ip := range arr(cm, "allowedIPs") {
if s, ok := ip.(string); ok {
cl.AllowedIPs = append(cl.AllowedIPs, s)
}
}
if rev, ok := cm["reverse"]; ok && rev != nil {
cl.Reverse = raw(rev)
}
in.Clients = append(in.Clients, cl)
}
return in
}