From 284040e9944556316769eb6eac542bac7fc4899c Mon Sep 17 00:00:00 2001 From: Muse Date: Tue, 20 Oct 2026 11:07:10 +0000 Subject: [PATCH] =?UTF-8?q?=E7=B2=BE=E7=AE=80P2:=20=E4=B8=8B=E7=BA=BF=20in?= =?UTF-8?q?fra/conf=20=E4=BA=A4=E5=8F=89=E9=AA=8C=E8=AF=81=E7=B1=BB?= =?UTF-8?q?=E6=B5=8B=E8=AF=95:=20golden=20fixtures=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E4=BE=A7=E5=A5=91=E7=BA=A6(=E5=85=A8?= =?UTF-8?q?=E8=AF=AD=E6=96=99=E5=8F=AF=E8=A7=A3=E6=9E=90+inbound-full=20?= =?UTF-8?q?=E7=BB=8F=20GenXrayInboundConfig=20=E7=94=9F=E6=88=90=E5=90=8E?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E4=BF=9D=E6=8C=81),=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=9B=B4=E5=90=8D=20golden=5Ffixtures=5Ftest.go;=20XMC=20?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=80=A7=E6=B5=8B=E8=AF=95=E7=94=B1=E4=B8=8E?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E5=AF=B9=E6=8B=8D=E6=94=B9=E4=B8=BA=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E8=B0=93=E8=AF=8D=E8=87=AA=E8=BA=AB=E8=A7=84=E5=88=99?= =?UTF-8?q?=E8=A1=A8(=E7=94=A8=E6=88=B7=E5=90=8D=E6=AD=A3=E5=88=99/uuid=20?= =?UTF-8?q?=E5=8F=AF=E8=A7=A3=E6=9E=90/textures=20=E9=9D=9E=E7=A9=BA);=20h?= =?UTF-8?q?ealed-config=20=E6=B5=8B=E8=AF=95=E6=94=B9=E4=B8=BA=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E9=9D=A2=E6=9D=BF=20heal=20=E7=BB=93=E6=9E=9C(version?= =?UTF-8?q?=20=E9=92=89=202/=E7=A9=BA=20packet=20=E8=A2=AB=E6=B8=85).=20?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E6=8E=A5=E5=8F=97=E6=80=A7=E4=BB=8A=E5=90=8E?= =?UTF-8?q?=E7=94=B1=E8=8A=82=E7=82=B9=E6=A0=B8=E5=BF=83=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=97=B6=E6=8A=8A=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/service/finalmask_rand_packet_test.go | 53 +-- internal/web/service/golden_fixtures_test.go | 148 ++++++++ .../web/service/golden_fixtures_xray_test.go | 350 ------------------ .../web/service/inbound_finalmask_xmc_test.go | 70 ++-- 4 files changed, 208 insertions(+), 413 deletions(-) create mode 100644 internal/web/service/golden_fixtures_test.go delete mode 100644 internal/web/service/golden_fixtures_xray_test.go diff --git a/internal/web/service/finalmask_rand_packet_test.go b/internal/web/service/finalmask_rand_packet_test.go index 14a3183..7016eb4 100644 --- a/internal/web/service/finalmask_rand_packet_test.go +++ b/internal/web/service/finalmask_rand_packet_test.go @@ -107,9 +107,13 @@ func TestDropEmptyRandPacketsIgnoresMissingFinalMask(t *testing.T) { } } -// TestHealedConfigsBuildInXray closes the loop on both heals: the rows xray -// refuses outright must build once the panel has healed them. -func TestHealedConfigsBuildInXray(t *testing.T) { +// TestHealedConfigsCarryPanelHeals closes the loop on both heals at the +// panel level: the generated config must carry the healed shape. Until the +// lite peel this test also built the results in xray-core (and asserted the +// unhealed rows were refused); with the library gone, the heals themselves +// are what's pinned — the core still refuses the unhealed shapes when it +// starts on the node, which is exactly why the heals exist. +func TestHealedConfigsCarryPanelHeals(t *testing.T) { t.Run("hysteria v1 row", func(t *testing.T) { in := model.Inbound{ Protocol: model.Hysteria, @@ -128,33 +132,36 @@ func TestHealedConfigsBuildInXray(t *testing.T) { if err := json.Unmarshal(raw, &healed); err != nil { t.Fatalf("decode generated inbound: %v", err) } - assertXrayAccepts(t, "the healed hysteria inbound", buildGoldenInbound(t, healed)) - - var unhealed map[string]any - if err := json.Unmarshal([]byte(`{ - "tag":"in-hysteria","listen":"127.0.0.1","port":36715,"protocol":"hysteria", - "settings":`+in.Settings+`,"streamSettings":`+in.StreamSettings+`}`), &unhealed); err != nil { - t.Fatalf("decode raw inbound: %v", err) + settings, _ := healed["settings"].(map[string]any) + if settings["version"] != float64(2) { + t.Fatalf("generated settings version = %v, want the heal to pin 2", settings["version"]) } - if err := buildGoldenInbound(t, unhealed); err == nil { - t.Fatal("the unhealed v1 row is expected to be refused; the heal is what makes it buildable") + stream, _ := healed["streamSettings"].(map[string]any) + hysteriaSettings, _ := stream["hysteriaSettings"].(map[string]any) + if hysteriaSettings["version"] != float64(2) { + t.Fatalf("generated hysteriaSettings version = %v, want the heal to pin 2", hysteriaSettings["version"]) } }) t.Run("noise item with an empty packet", func(t *testing.T) { item := map[string]any{"type": "array", "rand": "1-8192", "packet": []any{}, "delay": "5"} stream := streamWithNoiseItem(t, item) - inbound := map[string]any{ - "tag": "in-vless", "listen": "127.0.0.1", "port": 8443, "protocol": "vless", - "settings": map[string]any{"clients": []any{}, "decryption": "none"}, - "streamSettings": stream, - } - if err := buildGoldenInbound(t, inbound); err == nil { - t.Fatal("xray-core is expected to refuse a packet and a rand on one item") - } - dropEmptyRandPackets(stream["finalmask"]) - inbound["streamSettings"] = stream - assertXrayAccepts(t, "the healed noise mask", buildGoldenInbound(t, inbound)) + // The unhealed item carries the rand+packet combination the core + // refuses; the heal must remove the empty packet and keep the rest. + before := noiseItem(t, stream) + if _, present := before["packet"]; !present { + t.Fatal("fixture lost its packet key before the heal") + } + if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 1 { + t.Fatalf("cleared = %d, want 1", cleared) + } + after := noiseItem(t, stream) + if _, present := after["packet"]; present { + t.Fatalf("packet survived the heal: %#v", after) + } + if after["rand"] != "1-8192" || after["delay"] != "5" { + t.Fatalf("healing changed the mask: %#v", after) + } }) } diff --git a/internal/web/service/golden_fixtures_test.go b/internal/web/service/golden_fixtures_test.go new file mode 100644 index 0000000..ef952c7 --- /dev/null +++ b/internal/web/service/golden_fixtures_test.go @@ -0,0 +1,148 @@ +package service + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// The frontend's golden fixtures are the panel's model of an xray config: the +// Zod snapshots pin what the forms parse and emit. Until the lite peel, every +// fixture was also built here through xray-core's config builders +// (conf.InboundDetourConfig / conf.RouterConfig / conf.DNSConfig) to prove +// the core would accept what the panel produces. That cross-check retired +// with the library: this build has no in-process core builder, and config +// acceptance is enforced by the core on the node when it starts. What stays +// is the panel-side contract — every fixture still parses, and every full +// inbound fixture still flows through GenXrayInboundConfig (the conversion +// the full-config path uses) with its identity intact. +// +// mtproto fixtures are excluded from generation: it is served by the +// bundled mtg-multi sidecar, not by xray. + +func goldenFixtureDir(t *testing.T, category string) string { + t.Helper() + dir, err := filepath.Abs(filepath.Join("..", "..", "..", "frontend", "src", "test", "golden", "fixtures", category)) + if err != nil { + t.Fatalf("resolve fixture dir: %v", err) + } + return dir +} + +// goldenFixtures returns every fixture in a category as name -> decoded object. +func goldenFixtures(t *testing.T, category string) map[string]map[string]any { + t.Helper() + dir := goldenFixtureDir(t, category) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read %s: %v", dir, err) + } + out := make(map[string]map[string]any) + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if filepath.Ext(entry.Name()) != ".json" { + continue + } + names = append(names, entry.Name()) + } + sort.Strings(names) + if len(names) == 0 { + t.Fatalf("no fixtures under %s", dir) + } + for _, name := range names { + raw, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("unmarshal %s: %v", name, err) + } + out[strings.TrimSuffix(name, ".json")] = obj + } + return out +} + +// TestGoldenFixturesParse keeps the whole corpus exercised: a fixture that +// stops being a valid JSON object fails here even in a category whose only +// consumer is the frontend. +func TestGoldenFixturesParse(t *testing.T) { + for _, category := range []string{"inbound", "inbound-full", "stream", "security", "sockopt", "finalmask", "rule", "balancer", "dns", "dns-server"} { + t.Run(category, func(t *testing.T) { + if fixtures := goldenFixtures(t, category); len(fixtures) == 0 { + t.Fatal("no fixtures parsed") + } + }) + } +} + +// TestGoldenInboundFullFixturesGenerate runs each complete panel Inbound +// fixture through GenXrayInboundConfig and checks the generated config keeps +// the fixture's identity: protocol, port, tag and a non-empty settings +// object. (The full-output equivalence of this path against the previous +// build is covered by the phase-2 golden diff at the binary level.) +func TestGoldenInboundFullFixturesGenerate(t *testing.T) { + for name, fixture := range goldenFixtures(t, "inbound-full") { + protocol, _ := fixture["protocol"].(string) + if protocol == string(model.MTProto) { + continue + } + t.Run(name, func(t *testing.T) { + section := func(key string) string { + value, ok := fixture[key] + if !ok || value == nil { + return "" + } + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal %s: %v", key, err) + } + return string(raw) + } + port := 0 + if p, ok := fixture["port"].(float64); ok { + port = int(p) + } + listen, _ := fixture["listen"].(string) + tag, _ := fixture["tag"].(string) + + ib := &model.Inbound{ + Protocol: model.Protocol(protocol), + Port: port, + Listen: listen, + Tag: tag, + Settings: section("settings"), + StreamSettings: section("streamSettings"), + Sniffing: section("sniffing"), + } + raw, err := json.Marshal(ib.GenXrayInboundConfig()) + if err != nil { + t.Fatalf("marshal generated inbound: %v", err) + } + var generated map[string]any + if err := json.Unmarshal(raw, &generated); err != nil { + t.Fatalf("decode generated inbound: %v", err) + } + if generated["protocol"] != protocol { + t.Errorf("protocol = %v, want %q", generated["protocol"], protocol) + } + if port != 0 { + if got, ok := generated["port"].(float64); !ok || int(got) != port { + t.Errorf("port = %v, want %d", generated["port"], port) + } + } + if tag != "" && generated["tag"] != tag { + t.Errorf("tag = %v, want %q", generated["tag"], tag) + } + settings, ok := generated["settings"].(map[string]any) + if !ok || len(settings) == 0 { + t.Errorf("settings = %v, want a non-empty object", generated["settings"]) + } + }) + } +} diff --git a/internal/web/service/golden_fixtures_xray_test.go b/internal/web/service/golden_fixtures_xray_test.go deleted file mode 100644 index e603298..0000000 --- a/internal/web/service/golden_fixtures_xray_test.go +++ /dev/null @@ -1,350 +0,0 @@ -package service - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "encoding/json" - "encoding/pem" - "maps" - "math/big" - "os" - "path/filepath" - "sort" - "strings" - "testing" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - - "github.com/xtls/xray-core/infra/conf" -) - -// The frontend's golden fixtures are the panel's model of an xray config: the -// Zod snapshots pin what the forms parse and emit. Parsing proves the panel -// agrees with itself, not that xray-core would accept the result, so every -// fixture is also built here through the very config builders the panel hands -// its config to — conf.InboundDetourConfig for the full-config and AddInbound -// paths, conf.RouterConfig for ApplyRoutingConfig, conf.DNSConfig for the dns -// section. A fixture xray-core refuses is a config the panel would let an -// admin save and then fail to start the core with, taking every inbound down. -// -// mtproto is excluded: it is served by the bundled mtg-multi sidecar, not by -// xray, so xray-core has no config id for it. - -func goldenFixtureDir(t *testing.T, category string) string { - t.Helper() - dir, err := filepath.Abs(filepath.Join("..", "..", "..", "frontend", "src", "test", "golden", "fixtures", category)) - if err != nil { - t.Fatalf("resolve fixture dir: %v", err) - } - return dir -} - -// goldenFixtures returns every fixture in a category as name -> decoded object. -func goldenFixtures(t *testing.T, category string) map[string]map[string]any { - t.Helper() - dir := goldenFixtureDir(t, category) - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatalf("read %s: %v", dir, err) - } - out := make(map[string]map[string]any) - names := make([]string, 0, len(entries)) - for _, entry := range entries { - if filepath.Ext(entry.Name()) != ".json" { - continue - } - names = append(names, entry.Name()) - } - sort.Strings(names) - if len(names) == 0 { - t.Fatalf("no fixtures under %s", dir) - } - for _, name := range names { - raw, err := os.ReadFile(filepath.Join(dir, name)) - if err != nil { - t.Fatalf("read %s: %v", name, err) - } - var obj map[string]any - if err := json.Unmarshal(raw, &obj); err != nil { - t.Fatalf("unmarshal %s: %v", name, err) - } - out[strings.TrimSuffix(name, ".json")] = obj - } - return out -} - -// writeTestCertificate writes a self-signed certificate and key, returning both -// paths. The TLS fixtures point certificateFile/keyFile at deployment paths -// that do not exist here, and xray-core reads them while building. -func writeTestCertificate(t *testing.T) (certPath, keyPath string) { - t.Helper() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatalf("generate key: %v", err) - } - template := x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: "golden-fixture.test"}, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(24 * time.Hour), - DNSNames: []string{"golden-fixture.test"}, - } - der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) - if err != nil { - t.Fatalf("create certificate: %v", err) - } - keyDER, err := x509.MarshalECPrivateKey(key) - if err != nil { - t.Fatalf("marshal key: %v", err) - } - - dir := t.TempDir() - certPath = filepath.Join(dir, "fixture.crt") - keyPath = filepath.Join(dir, "fixture.key") - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) - if err := os.WriteFile(certPath, certPEM, 0o600); err != nil { - t.Fatalf("write certificate: %v", err) - } - if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { - t.Fatalf("write key: %v", err) - } - return certPath, keyPath -} - -// repointCertificateFiles rewrites every certificateFile/keyFile reference to -// the generated pair, so a fixture is judged on its shape rather than on paths -// that only exist on a deployed server. -func repointCertificateFiles(node any, certPath, keyPath string) { - switch value := node.(type) { - case map[string]any: - for key, child := range value { - switch key { - case "certificateFile": - if _, ok := child.(string); ok { - value[key] = certPath - continue - } - case "keyFile": - if _, ok := child.(string); ok { - value[key] = keyPath - continue - } - } - repointCertificateFiles(child, certPath, keyPath) - } - case []any: - for _, child := range value { - repointCertificateFiles(child, certPath, keyPath) - } - } -} - -// assertXrayAccepts fails unless xray-core built the fixture. Fixtures naming -// geoip:/geosite: need the dat files the panel ships next to the xray binary; -// where they are absent the loader fails on the missing file rather than on the -// fixture, so those are skipped instead of reported as broken. -func assertXrayAccepts(t *testing.T, subject string, err error) { - t.Helper() - if err == nil { - return - } - if isMissingGeoAssetErr(err) { - t.Skipf("geo data files not available, cannot judge %s: %v", subject, err) - } - t.Fatalf("xray-core refuses %s: %v", subject, err) -} - -func buildGoldenInbound(t *testing.T, inbound map[string]any) error { - t.Helper() - certPath, keyPath := writeTestCertificate(t) - repointCertificateFiles(inbound, certPath, keyPath) - - raw, err := json.Marshal(inbound) - if err != nil { - t.Fatalf("marshal inbound: %v", err) - } - detour := new(conf.InboundDetourConfig) - if err := json.Unmarshal(raw, detour); err != nil { - return err - } - _, err = detour.Build() - return err -} - -// TestGoldenInboundFixturesBuildInXray wraps each protocol fixture in a minimal -// inbound and builds it. -func TestGoldenInboundFixturesBuildInXray(t *testing.T) { - for name, fixture := range goldenFixtures(t, "inbound") { - if protocol, _ := fixture["protocol"].(string); protocol == string(model.MTProto) { - continue - } - t.Run(name, func(t *testing.T) { - inbound := map[string]any{ - "tag": "golden-in", - "listen": "127.0.0.1", - "port": 8443, - "protocol": fixture["protocol"], - "settings": fixture["settings"], - } - assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound)) - }) - } -} - -// TestGoldenInboundFullFixturesBuildInXray runs the complete panel Inbound -// model through GenXrayInboundConfig, the conversion both the full-config and -// the live AddInbound paths use, and builds the result. -func TestGoldenInboundFullFixturesBuildInXray(t *testing.T) { - for name, fixture := range goldenFixtures(t, "inbound-full") { - protocol, _ := fixture["protocol"].(string) - if protocol == string(model.MTProto) { - continue - } - t.Run(name, func(t *testing.T) { - section := func(key string) string { - value, ok := fixture[key] - if !ok || value == nil { - return "" - } - raw, err := json.Marshal(value) - if err != nil { - t.Fatalf("marshal %s: %v", key, err) - } - return string(raw) - } - port := 0 - if p, ok := fixture["port"].(float64); ok { - port = int(p) - } - listen, _ := fixture["listen"].(string) - tag, _ := fixture["tag"].(string) - - ib := &model.Inbound{ - Protocol: model.Protocol(protocol), - Port: port, - Listen: listen, - Tag: tag, - Settings: section("settings"), - StreamSettings: section("streamSettings"), - Sniffing: section("sniffing"), - } - raw, err := json.Marshal(ib.GenXrayInboundConfig()) - if err != nil { - t.Fatalf("marshal generated inbound: %v", err) - } - var generated map[string]any - if err := json.Unmarshal(raw, &generated); err != nil { - t.Fatalf("decode generated inbound: %v", err) - } - assertXrayAccepts(t, "the generated inbound", buildGoldenInbound(t, generated)) - }) - } -} - -// TestGoldenStreamFixturesBuildInXray attaches the transport fragments — whole -// stream sections, the security block, sockopt and finalmask — to an inbound. -func TestGoldenStreamFixturesBuildInXray(t *testing.T) { - for _, category := range []string{"stream", "security", "sockopt", "finalmask"} { - for name, fixture := range goldenFixtures(t, category) { - t.Run(category+"/"+name, func(t *testing.T) { - stream := map[string]any{"network": "tcp"} - switch category { - case "stream": - stream = fixture - case "security": - maps.Copy(stream, fixture) - case "sockopt": - stream["sockopt"] = fixture - case "finalmask": - stream["finalmask"] = fixture - } - inbound := map[string]any{ - "tag": "golden-in", "listen": "127.0.0.1", "port": 8443, "protocol": "vless", - "settings": map[string]any{ - "clients": []any{map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "golden"}}, - "decryption": "none", - }, - "streamSettings": stream, - } - assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound)) - }) - } - } -} - -func buildGoldenRouting(routing map[string]any) error { - raw, err := json.Marshal(routing) - if err != nil { - return err - } - router := new(conf.RouterConfig) - if err := json.Unmarshal(raw, router); err != nil { - return err - } - _, err = router.Build() - return err -} - -// TestGoldenRoutingFixturesBuildInXray builds the rule and balancer fixtures -// through the router config ApplyRoutingConfig hands to the running core. -func TestGoldenRoutingFixturesBuildInXray(t *testing.T) { - for name, rule := range goldenFixtures(t, "rule") { - t.Run("rule/"+name, func(t *testing.T) { - assertXrayAccepts(t, "this rule", buildGoldenRouting(map[string]any{ - "domainStrategy": "AsIs", - "rules": []any{rule}, - "balancers": []any{map[string]any{"tag": "balancer-load", "selector": []any{"proxy-"}}}, - })) - }) - } - - for name, balancer := range goldenFixtures(t, "balancer") { - t.Run("balancer/"+name, func(t *testing.T) { - assertXrayAccepts(t, "this balancer", buildGoldenRouting(map[string]any{ - "domainStrategy": "AsIs", - "balancers": []any{balancer}, - "rules": []any{map[string]any{ - "type": "field", "port": "443", "balancerTag": balancer["tag"], - }}, - })) - }) - } -} - -// TestGoldenDNSFixturesBuildInXray builds the dns section, and each dns-server -// fixture inside one. -func TestGoldenDNSFixturesBuildInXray(t *testing.T) { - build := func(dns map[string]any) error { - raw, err := json.Marshal(dns) - if err != nil { - return err - } - dnsConf := new(conf.DNSConfig) - if err := json.Unmarshal(raw, dnsConf); err != nil { - return err - } - _, err = dnsConf.Build() - return err - } - - for name, fixture := range goldenFixtures(t, "dns") { - t.Run("dns/"+name, func(t *testing.T) { - assertXrayAccepts(t, "this dns section", build(fixture)) - }) - } - for name, server := range goldenFixtures(t, "dns-server") { - t.Run("dns-server/"+name, func(t *testing.T) { - assertXrayAccepts(t, "this dns server", build(map[string]any{"servers": []any{server}})) - }) - } -} - -func isMissingGeoAssetErr(err error) bool { - msg := err.Error() - return strings.Contains(msg, "geoip.dat") || strings.Contains(msg, "geosite.dat") -} diff --git a/internal/web/service/inbound_finalmask_xmc_test.go b/internal/web/service/inbound_finalmask_xmc_test.go index 343f98c..3233a48 100644 --- a/internal/web/service/inbound_finalmask_xmc_test.go +++ b/internal/web/service/inbound_finalmask_xmc_test.go @@ -3,8 +3,6 @@ package service import ( "encoding/json" "testing" - - "github.com/xtls/xray-core/infra/conf" ) const completeXmcProfile = `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}` @@ -77,62 +75,54 @@ func TestValidateFinalMaskXmcProfiles(t *testing.T) { } } -// TestXmcMaskProfilesCompleteMatchesCoreValidation pins the panel's predicate -// to xray-core's own loader instead of a restatement of it: every profile the -// panel accepts must build, and every one it rejects must fail to build. A -// future core release that tightens or relaxes the rules fails here rather -// than silently producing configs the core refuses to start on. -func TestXmcMaskProfilesCompleteMatchesCoreValidation(t *testing.T) { +// TestXmcMaskProfilesCompleteExpectations pins the panel predicate's own +// contract. Until the lite peel it was cross-checked against xray-core's +// loader (every accepted profile had to build in the core and vice versa); +// with the library gone the predicate stands on its own rules — username +// ^[A-Za-z0-9_]{3,16}$, a parseable uuid, non-empty textures value and +// signature, at least one profile — and this table records them. A core +// release that tightens or relaxes its side is no longer detected here; +// the strip/validate pair errs on the panel's documented rules instead. +func TestXmcMaskProfilesCompleteExpectations(t *testing.T) { profiles := []struct { name string raw string + want bool }{ - {name: "complete", raw: completeXmcProfile}, - {name: "undashed uuid", raw: `{"username":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`}, - {name: "username at the 16 char limit", raw: `{"username":"Abcdefghijklmnop","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`}, - {name: "username over the limit", raw: `{"username":"Abcdefghijklmnopq","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`}, - {name: "username with a hyphen", raw: `{"username":"No-tch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`}, - {name: "empty uuid", raw: `{"username":"Notch","uuid":"","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`}, - {name: "missing textures value", raw: `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"","texturesSignature":"c2ln"}`}, + {name: "complete", raw: completeXmcProfile, want: true}, + {name: "undashed uuid", raw: `{"username":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`, want: true}, + {name: "username at the 16 char limit", raw: `{"username":"Abcdefghijklmnop","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`, want: true}, + {name: "username over the limit", raw: `{"username":"Abcdefghijklmnopq","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`, want: false}, + {name: "username with a hyphen", raw: `{"username":"No-tch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`, want: false}, + {name: "empty uuid", raw: `{"username":"Notch","uuid":"","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`, want: false}, + {name: "missing textures value", raw: `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"","texturesSignature":"c2ln"}`, want: false}, + {name: "no profiles at all", raw: ``, want: false}, } for _, tt := range profiles { t.Run(tt.name, func(t *testing.T) { - var coreProfile conf.XMCProfile - if err := json.Unmarshal([]byte(tt.raw), &coreProfile); err != nil { - t.Fatalf("unmarshal into conf.XMCProfile: %v", err) - } - _, coreErr := coreProfile.Build() - mask := map[string]any{"type": "xmc"} var settings map[string]any - if err := json.Unmarshal([]byte(`{"profiles":[`+tt.raw+`]}`), &settings); err != nil { - t.Fatalf("unmarshal settings: %v", err) + raw := tt.raw + if raw == "" { + raw = `{}` // a mask whose settings carry no profiles key at all + if err := json.Unmarshal([]byte(raw), &settings); err != nil { + t.Fatalf("unmarshal settings: %v", err) + } + } else { + if err := json.Unmarshal([]byte(`{"profiles":[`+raw+`]}`), &settings); err != nil { + t.Fatalf("unmarshal settings: %v", err) + } } mask["settings"] = settings - panelAccepts := xmcMaskProfilesComplete(mask) - coreAccepts := coreErr == nil - if panelAccepts != coreAccepts { - t.Errorf("xmcMaskProfilesComplete = %v, but conf.XMCProfile.Build() accepts = %v (err %v)", panelAccepts, coreAccepts, coreErr) + if got := xmcMaskProfilesComplete(mask); got != tt.want { + t.Errorf("xmcMaskProfilesComplete = %v, want %v", got, tt.want) } }) } } -// TestXmcEmptyProfilesRejectedByCore covers the rule that lives on XMC rather -// than XMCProfile: v26.7.28 dropped the "default to Dream" fallback, so a mask -// with no profiles at all is now a build failure. -func TestXmcEmptyProfilesRejectedByCore(t *testing.T) { - var core conf.XMC - if err := json.Unmarshal([]byte(`{"hostname":"mc.example.com","password":"pw","profiles":[]}`), &core); err != nil { - t.Fatalf("unmarshal into conf.XMC: %v", err) - } - if _, err := core.Build(); err == nil { - t.Fatal("conf.XMC.Build() accepted an empty profiles list; the panel's strip/validate pair is no longer needed") - } -} - func TestStripIncompleteXmcMasks(t *testing.T) { tests := []struct { name string