Files
3x-ui/internal/web/service/server_geofile_test.go
T
MHSanaei 12d51d7195 perf(tests): copy a migrated template DB instead of migrating per test
Most tests opened a throwaway panel DB with database.InitDB, which runs the
full AutoMigrate + seed on an empty file every time: ~230ms, and ~850ms under
-race because GORM's reflection-heavy migration is what the detector slows
most. internal/web/service does this in ~550 of its 830 tests, so the CI race
job spent ~10 of its ~14.6 minutes re-migrating empty databases.

internal/database/dbtest.InitDB migrates once per test process, then hands
each test its own copy of that file (~130ms under -race) and registers the
CloseDB cleanup. The copy then goes through InitDB like a panel restart, so
every test still starts from the state a fresh install has. Tests that reopen
an existing file, migrate a hand-built legacy DB or target Postgres keep
calling database.InitDB.

Locally under -race: internal/web/service 626s (last CI run) -> 114s,
internal/sub 246s -> 35s.
2026-09-27 03:04:50 +02:00

413 lines
14 KiB
Go

package service
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
)
// Loyalsoldier and runetfreedom write "<hash> geoip.dat"; chocolate4u writes
// "<hash> release/geoip.dat", the path from its own build.
func TestParseGeofileDigest(t *testing.T) {
const digest = "0d5d2ba0c5a5c58027fd1347a6afd57c9470799b6bb3cbc274fd4657ed8de382"
for _, tc := range []struct {
name string
sums string
asset string
want string
}{
{"bare-name", digest + " geoip.dat\n", "geoip.dat", digest},
{"build-path", digest + " release/geoip.dat\n", "geoip.dat", digest},
{"binary-mode-marker", digest + " *geoip.dat\n", "geoip.dat", digest},
{"uppercase-digest", strings.ToUpper(digest) + " geoip.dat\n", "geoip.dat", digest},
{"picks-matching-line", "aaaa geosite.dat\n" + digest + " geoip.dat\n", "geoip.dat", digest},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := parseGeofileDigest([]byte(tc.sums), tc.asset)
if err != nil {
t.Fatalf("parse: %v", err)
}
if got != tc.want {
t.Fatalf("digest = %q, want %q", got, tc.want)
}
})
}
}
func TestParseGeofileDigest_Errors(t *testing.T) {
const digest = "0d5d2ba0c5a5c58027fd1347a6afd57c9470799b6bb3cbc274fd4657ed8de382"
for _, tc := range []struct {
name string
sums string
asset string
wantErr string
}{
// Accepting this would verify geoip.dat against geosite.dat's digest.
{"names-another-asset", digest + " geosite.dat\n", "geoip.dat", "no SHA-256 entry for geoip.dat"},
{"empty", "", "geoip.dat", "no SHA-256 entry for geoip.dat"},
{"malformed-short", "deadbeef geoip.dat\n", "geoip.dat", "malformed SHA-256 entry for geoip.dat"},
{"not-hex", strings.Repeat("z", 64) + " geoip.dat\n", "geoip.dat", "malformed SHA-256 entry for geoip.dat"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := parseGeofileDigest([]byte(tc.sums), tc.asset)
if err == nil {
t.Fatalf("%s: expected an error", tc.name)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %q, want it to contain %q", err, tc.wantErr)
}
})
}
}
func TestGeofileTagFromLocation(t *testing.T) {
got, err := geofileTagFromLocation("https://github.com/o/r/releases/download/202609022346/geoip.dat")
if err != nil {
t.Fatalf("parse: %v", err)
}
if got != "202609022346" {
t.Fatalf("tag = %q, want 202609022346", got)
}
for _, bad := range []string{
"https://github.com/o/r/releases/latest/download/geoip.dat",
"https://github.com/o/r/releases/download/202609022346",
"",
} {
if _, err := geofileTagFromLocation(bad); err == nil {
t.Fatalf("expected an error for %q", bad)
}
}
}
// fakeUpstream serves one repo's release: a `latest` download redirecting to a
// tagged asset, the asset itself, and its .sha256sum sidecar.
type fakeUpstream struct {
repo string
assets map[string]string
corrupt map[string]bool
}
// geofileServer mounts every upstream on one test server, mimicking GitHub's
// `releases/latest/download` -> `releases/download/<tag>` redirect.
func geofileServer(t *testing.T, ups []fakeUpstream) (*httptest.Server, *sync.Map) {
t.Helper()
hits := &sync.Map{}
mux := http.NewServeMux()
for _, up := range ups {
for asset, body := range up.assets {
tagged := "/" + up.repo + "/releases/download/v1/" + asset
mux.HandleFunc("/"+up.repo+"/releases/latest/download/"+asset, func(w http.ResponseWriter, r *http.Request) {
hits.Store("latest:"+up.repo, true)
http.Redirect(w, r, tagged, http.StatusFound)
})
mux.HandleFunc(tagged, func(w http.ResponseWriter, r *http.Request) {
hits.Store("body:"+up.repo+"/"+asset, true)
_, _ = w.Write([]byte(body))
})
payload := body
if up.corrupt[asset] {
payload = body + " tampered"
}
sum := sha256.Sum256([]byte(payload))
line := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), asset)
mux.HandleFunc(tagged+".sha256sum", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(line))
})
}
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, hits
}
// geofileTestEnv points the service at a temp asset folder and a throwaway DB.
func geofileTestEnv(t *testing.T, entries map[string]geofileEntry) string {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
binFolder := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", binFolder)
originalAllowlist := geofileAllowlist
geofileAllowlist = entries
t.Cleanup(func() { geofileAllowlist = originalAllowlist })
return binFolder
}
func restartStub(t *testing.T, called *bool) {
t.Helper()
original := restartXrayAfterGeofileUpdate
restartXrayAfterGeofileUpdate = func(*ServerService) error {
*called = true
return nil
}
t.Cleanup(func() { restartXrayAfterGeofileUpdate = original })
}
func TestUpdateGeofileInstallsVerifiedFile(t *testing.T) {
srv, _ := geofileServer(t, []fakeUpstream{{repo: "a", assets: map[string]string{"geoip.dat": "good geoip payload"}}})
binFolder := geofileTestEnv(t, map[string]geofileEntry{
"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
})
var restarted bool
restartStub(t, &restarted)
if err := (&ServerService{}).UpdateGeofile(""); err != nil {
t.Fatalf("UpdateGeofile: %v", err)
}
got, err := os.ReadFile(filepath.Join(binFolder, "geoip.dat"))
if err != nil {
t.Fatalf("read installed geofile: %v", err)
}
if string(got) != "good geoip payload" {
t.Fatalf("installed content = %q, want %q", got, "good geoip payload")
}
if !restarted {
t.Fatal("a file was installed, so xray should have been restarted")
}
}
func TestUpdateGeofileRejectsDigestMismatch(t *testing.T) {
srv, _ := geofileServer(t, []fakeUpstream{{
repo: "a",
assets: map[string]string{"geoip.dat": "good geoip payload"},
corrupt: map[string]bool{"geoip.dat": true},
}})
binFolder := geofileTestEnv(t, map[string]geofileEntry{
"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
})
var restarted bool
restartStub(t, &restarted)
err := (&ServerService{}).UpdateGeofile("")
if err == nil {
t.Fatal("expected an error when the download does not match its published digest")
}
if !strings.Contains(err.Error(), "does not match the published SHA-256 checksum") {
t.Fatalf("error = %q, want it to name the checksum mismatch", err)
}
if _, statErr := os.Stat(filepath.Join(binFolder, "geoip.dat")); !os.IsNotExist(statErr) {
t.Fatalf("a file failing verification must not be installed (stat: %v)", statErr)
}
if restarted {
t.Fatal("nothing was installed, so xray must not be restarted")
}
}
// Within one upstream the pair installs together. geoip sorts before geosite
// and is staged first, so a trivially-passing "abort before download" is ruled out.
func TestUpdateGeofileInstallsNeitherFileOfAFailedUpstream(t *testing.T) {
srv, hits := geofileServer(t, []fakeUpstream{{
repo: "a",
assets: map[string]string{"geoip.dat": "good geoip", "geosite.dat": "good geosite"},
corrupt: map[string]bool{"geosite.dat": true},
}})
binFolder := geofileTestEnv(t, map[string]geofileEntry{
"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
"geosite.dat": {srv.URL + "/a", "geosite.dat", "geosite.dat"},
})
var restarted bool
restartStub(t, &restarted)
if err := (&ServerService{}).UpdateGeofile(""); err == nil {
t.Fatal("expected an error when one of the databases fails verification")
}
if _, ok := hits.Load("body:a/geoip.dat"); !ok {
t.Fatal("geoip.dat was never downloaded, so this run never exercised staging")
}
for _, name := range []string{"geoip.dat", "geosite.dat"} {
if _, statErr := os.Stat(filepath.Join(binFolder, name)); !os.IsNotExist(statErr) {
t.Fatalf("%s was installed even though its sibling failed verification", name)
}
}
if restarted {
t.Fatal("nothing was installed, so xray must not be restarted")
}
}
// A broken upstream must not discard a healthy one's verified download.
func TestUpdateGeofileKeepsGoodUpstreamWhenAnotherFails(t *testing.T) {
srv, _ := geofileServer(t, []fakeUpstream{
{repo: "aaa", assets: map[string]string{"geoip.dat": "healthy payload"}},
{
repo: "zzz",
assets: map[string]string{"geoip.dat": "broken payload"},
corrupt: map[string]bool{"geoip.dat": true},
},
})
binFolder := geofileTestEnv(t, map[string]geofileEntry{
"geoip.dat": {srv.URL + "/aaa", "geoip.dat", "geoip.dat"},
"geoip_RU.dat": {srv.URL + "/zzz", "geoip.dat", "geoip_RU.dat"},
})
var restarted bool
restartStub(t, &restarted)
err := (&ServerService{}).UpdateGeofile("")
if err == nil {
t.Fatal("expected an error naming the failing upstream")
}
if !strings.Contains(err.Error(), "geoip_RU.dat") {
t.Fatalf("error = %q, want it to name geoip_RU.dat", err)
}
got, readErr := os.ReadFile(filepath.Join(binFolder, "geoip.dat"))
if readErr != nil {
t.Fatalf("the healthy upstream's file must still be installed: %v", readErr)
}
if string(got) != "healthy payload" {
t.Fatalf("installed content = %q, want %q", got, "healthy payload")
}
if _, statErr := os.Stat(filepath.Join(binFolder, "geoip_RU.dat")); !os.IsNotExist(statErr) {
t.Fatal("the failing upstream's file must not be installed")
}
if !restarted {
t.Fatal("a file was installed, so xray should have been restarted")
}
}
// The upstreams publish several times a day. Once `latest` is resolved, the
// asset and its digest must both come from that release, not from a newer one.
func TestUpdateGeofileSurvivesReleaseRotation(t *testing.T) {
const oldBody = "release one payload"
oldSum := sha256.Sum256([]byte(oldBody))
newSum := sha256.Sum256([]byte("release two payload"))
mux := http.NewServeMux()
mux.HandleFunc("/a/releases/latest/download/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/a/releases/download/v1/geoip.dat", http.StatusFound)
})
mux.HandleFunc("/a/releases/download/v1/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(oldBody))
})
mux.HandleFunc("/a/releases/download/v1/geoip.dat.sha256sum", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(fmt.Appendf(nil, "%s geoip.dat\n", hex.EncodeToString(oldSum[:])))
})
// "latest" has already moved on to v2. Anything still resolving it gets a
// digest for bytes we never downloaded.
mux.HandleFunc("/a/releases/latest/download/geoip.dat.sha256sum", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(fmt.Appendf(nil, "%s geoip.dat\n", hex.EncodeToString(newSum[:])))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
binFolder := geofileTestEnv(t, map[string]geofileEntry{
"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
})
var restarted bool
restartStub(t, &restarted)
if err := (&ServerService{}).UpdateGeofile(""); err != nil {
t.Fatalf("a release landing mid-batch must not look like tampering: %v", err)
}
if got, err := os.ReadFile(filepath.Join(binFolder, "geoip.dat")); err != nil || string(got) != oldBody {
t.Fatalf("installed = %q (err %v), want the pinned release's bytes", got, err)
}
}
func TestUpdateGeofileSkipsRestartWhenNotModified(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/a/releases/latest/download/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/a/releases/download/v1/geoip.dat", http.StatusFound)
})
mux.HandleFunc("/a/releases/download/v1/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-Modified-Since") == "" {
t.Errorf("expected a conditional GET carrying If-Modified-Since")
}
w.WriteHeader(http.StatusNotModified)
})
mux.HandleFunc("/a/releases/download/v1/geoip.dat.sha256sum", func(w http.ResponseWriter, r *http.Request) {
t.Error("the sidecar must not be fetched when the asset is unchanged")
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
binFolder := geofileTestEnv(t, map[string]geofileEntry{
"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
})
existing := filepath.Join(binFolder, "geoip.dat")
if err := os.WriteFile(existing, []byte("already current"), 0o644); err != nil {
t.Fatalf("seed existing geofile: %v", err)
}
var restarted bool
restartStub(t, &restarted)
if err := (&ServerService{}).UpdateGeofile(""); err != nil {
t.Fatalf("UpdateGeofile: %v", err)
}
if restarted {
t.Fatal("a 304 from every upstream must not restart xray and drop client connections")
}
got, err := os.ReadFile(existing)
if err != nil {
t.Fatalf("read existing geofile: %v", err)
}
if string(got) != "already current" {
t.Fatalf("existing content = %q, want it left alone", got)
}
}
func TestUpdateGeofileRejectsNameOutsideAllowlist(t *testing.T) {
geofileTestEnv(t, map[string]geofileEntry{
"geoip.dat": {"https://example.invalid", "geoip.dat", "geoip.dat"},
})
err := (&ServerService{}).UpdateGeofile("../../etc/passwd")
if err == nil {
t.Fatal("expected an error for a name outside the allowlist")
}
if !strings.Contains(err.Error(), "not in allowlist") {
t.Fatalf("error = %q, want it to name the allowlist", err)
}
}
func TestStandardGeodataSources(t *testing.T) {
want := []GeodataSource{
{URL: "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", File: "geoip.dat"},
{URL: "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", File: "geoip_IR.dat"},
{URL: "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", File: "geoip_RU.dat"},
{URL: "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", File: "geosite.dat"},
{URL: "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", File: "geosite_IR.dat"},
{URL: "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", File: "geosite_RU.dat"},
}
got := StandardGeodataSources()
if len(got) != len(want) {
t.Fatalf("sources = %d entries, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("source %d = %+v, want %+v", i, got[i], want[i])
}
}
}