- 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>
187 lines
5.2 KiB
Go
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
|
|
}
|