精简P2: 下线 infra/conf 交叉验证类测试: golden fixtures 改为面板侧契约(全语料可解析+inbound-full 经 GenXrayInboundConfig 生成后身份保持), 文件更名 golden_fixtures_test.go; XMC 完整性测试由与核心对拍改为面板谓词自身规则表(用户名正则/uuid 可解析/textures 非空); healed-config 测试改为断言面板 heal 结果(version 钉 2/空 packet 被清). 核心接受性今后由节点核心启动时把关

This commit is contained in:
Muse
2026-10-20 11:07:10 +00:00
parent 70d24d2f15
commit 284040e994
4 changed files with 208 additions and 413 deletions
@@ -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)
}
})
}
@@ -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"])
}
})
}
}
@@ -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")
}
@@ -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