geoip: tests — known-address calibration, source order, pin precedence, updater download/validate/replace, contract frame
This commit is contained in:
@@ -601,3 +601,61 @@ func TestProbeWebSocket(t *testing.T) {
|
|||||||
func itoa(v int64) string {
|
func itoa(v int64) string {
|
||||||
return strconv.FormatInt(v, 10)
|
return strconv.FormatInt(v, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Q7: the resolved country reaches both frames. The seed's held
|
||||||
|
// address is a documentation range (unlisted in any country DB), so
|
||||||
|
// repoint it at a real public address whose country the embedded
|
||||||
|
// DB-IP snapshot knows — 8.8.8.8 is US there.
|
||||||
|
func TestProbePublicCountryFlag(t *testing.T) {
|
||||||
|
rig := newProbeTestRig(t)
|
||||||
|
pubID, _ := rig.seedAgent(t, "FlagNode", true)
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
var agent model.AgentNode
|
||||||
|
if err := db.First(&agent, pubID).Error; err != nil {
|
||||||
|
t.Fatalf("load seeded agent: %v", err)
|
||||||
|
}
|
||||||
|
agent.IPv4 = "8.8.8.8"
|
||||||
|
if err := db.Save(&agent).Error; err != nil {
|
||||||
|
t.Fatalf("repoint agent address: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rig.setSetting(t, "public_page", "on")
|
||||||
|
service.InvalidateProbeSnapshot()
|
||||||
|
|
||||||
|
find := func(rec *httptest.ResponseRecorder) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var frame struct {
|
||||||
|
Nodes []map[string]any `json:"nodes"`
|
||||||
|
Admin bool `json:"admin"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &frame); err != nil {
|
||||||
|
t.Fatalf("frame decode: %v", err)
|
||||||
|
}
|
||||||
|
for _, n := range frame.Nodes {
|
||||||
|
if n["id"] == float64(pubID) {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("seeded node missing from frame: %s", rec.Body.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := rig.do(http.MethodGet, "/api/nodes", nil, false)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("nodes anon: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := find(rec)["country"]; got != "US" {
|
||||||
|
t.Fatalf("anonymous country = %v, want US", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = rig.do(http.MethodGet, "/api/nodes", nil, true)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("nodes admin: %d", rec.Code)
|
||||||
|
}
|
||||||
|
admin := find(rec)
|
||||||
|
if admin["country"] != "US" || admin["country_auto"] != "US" || admin["country_pin"] != "" {
|
||||||
|
t.Fatalf("admin country triple = (%v, %v, %v), want (US, US, empty)",
|
||||||
|
admin["country"], admin["country_auto"], admin["country_pin"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oschwald/maxminddb-golang"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Q7 node country flags: local MMDB resolution on the master. The
|
||||||
|
// known-address expectations below are calibrated against the
|
||||||
|
// embedded factory snapshot (DB-IP Lite country database, retrieved
|
||||||
|
// 2026-10-24; provenance in geoipdata/NOTICE): these are long-lived
|
||||||
|
// anycast/ISP addresses whose country attribution is stable across
|
||||||
|
// editions — 8.8.8.8 (Google DNS) US, 223.5.5.5 (AliDNS) CN,
|
||||||
|
// 1.1.1.1 (Cloudflare/APNIC) AU, 2400:3200::1 (AliDNS v6) CN.
|
||||||
|
|
||||||
|
// useEmbeddedGeoIP swaps the embedded snapshot in as the live
|
||||||
|
// database for one test, restoring whatever was live before.
|
||||||
|
func useEmbeddedGeoIP(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
prev := currentGeoIP() // fire the lazy load so the Once is spent
|
||||||
|
r, err := maxminddb.FromBytes(geoipCountryEmbedded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("embedded snapshot will not open: %v", err)
|
||||||
|
}
|
||||||
|
swapGeoIP(&geoipDB{reader: r, origin: "embedded", cache: map[string]string{}})
|
||||||
|
t.Cleanup(func() { swapGeoIP(prev) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountryCodeForIPKnownAddresses(t *testing.T) {
|
||||||
|
useEmbeddedGeoIP(t)
|
||||||
|
for ip, want := range map[string]string{
|
||||||
|
"8.8.8.8": "US",
|
||||||
|
"223.5.5.5": "CN",
|
||||||
|
"1.1.1.1": "AU",
|
||||||
|
"2400:3200::1": "CN",
|
||||||
|
" 8.8.8.8 ": "US", // surrounding space is trimmed
|
||||||
|
} {
|
||||||
|
if got := countryCodeForIP(ip); got != want {
|
||||||
|
t.Errorf("countryCodeForIP(%q) = %q, want %q", ip, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-public and unlisted addresses have no country.
|
||||||
|
for _, ip := range []string{
|
||||||
|
"192.168.0.1", // RFC1918
|
||||||
|
"10.0.0.5", // RFC1918
|
||||||
|
"127.0.0.1", // loopback
|
||||||
|
"100.64.0.1", // CGNAT shared range
|
||||||
|
"203.0.113.9", // public documentation range: unlisted in the DB
|
||||||
|
"", // empty
|
||||||
|
"example.com", // not a literal
|
||||||
|
"999.1.1.1", // not an address
|
||||||
|
} {
|
||||||
|
if got := countryCodeForIP(ip); got != "" {
|
||||||
|
t.Errorf("countryCodeForIP(%q) = %q, want empty", ip, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeCountryAutoSourceOrder(t *testing.T) {
|
||||||
|
useEmbeddedGeoIP(t)
|
||||||
|
// Held v4 leads over held v6 (hub order).
|
||||||
|
a := &model.AgentNode{IPv4: "8.8.8.8", IPv6: "2400:3200::1", SeenIPv4: "223.5.5.5"}
|
||||||
|
if got := probeCountryAuto(a); got != "US" {
|
||||||
|
t.Errorf("held v4 first: got %q, want US", got)
|
||||||
|
}
|
||||||
|
// A private held v4 is skipped; the held public v6 beats the
|
||||||
|
// observed exit v4.
|
||||||
|
a = &model.AgentNode{IPv4: "10.0.0.5", IPv6: "2400:3200::1", SeenIPv4: "8.8.8.8"}
|
||||||
|
if got := probeCountryAuto(a); got != "CN" {
|
||||||
|
t.Errorf("held v6 over exit v4: got %q, want CN", got)
|
||||||
|
}
|
||||||
|
// Exit addresses are the fallback when the node holds none.
|
||||||
|
a = &model.AgentNode{SeenIPv4: "223.5.5.5"}
|
||||||
|
if got := probeCountryAuto(a); got != "CN" {
|
||||||
|
t.Errorf("exit v4 fallback: got %q, want CN", got)
|
||||||
|
}
|
||||||
|
a = &model.AgentNode{SeenIPv6: "2400:3200::1"}
|
||||||
|
if got := probeCountryAuto(a); got != "CN" {
|
||||||
|
t.Errorf("exit v6 fallback: got %q, want CN", got)
|
||||||
|
}
|
||||||
|
// No public address anywhere: no country.
|
||||||
|
a = &model.AgentNode{IPv4: "192.168.1.2", SeenIPv4: "10.1.1.1"}
|
||||||
|
if got := probeCountryAuto(a); got != "" {
|
||||||
|
t.Errorf("private only: got %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeCountryPinWins(t *testing.T) {
|
||||||
|
useEmbeddedGeoIP(t)
|
||||||
|
a := &model.AgentNode{IPv4: "223.5.5.5"}
|
||||||
|
country, auto := probeCountry("JP", a)
|
||||||
|
if country != "JP" || auto != "CN" {
|
||||||
|
t.Errorf("pin set: got (%q, %q), want (JP, CN)", country, auto)
|
||||||
|
}
|
||||||
|
country, auto = probeCountry("", a)
|
||||||
|
if country != "CN" || auto != "CN" {
|
||||||
|
t.Errorf("no pin: got (%q, %q), want (CN, CN)", country, auto)
|
||||||
|
}
|
||||||
|
country, auto = probeCountry("JP", &model.AgentNode{})
|
||||||
|
if country != "JP" || auto != "" {
|
||||||
|
t.Errorf("pin without address: got (%q, %q), want (JP, empty)", country, auto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildProbeNodeViewCountry(t *testing.T) {
|
||||||
|
useEmbeddedGeoIP(t)
|
||||||
|
src := probeNodeSource{
|
||||||
|
name: "flagged",
|
||||||
|
agent: model.AgentNode{IPv4: "8.8.8.8"},
|
||||||
|
meta: probe.Meta{Public: true},
|
||||||
|
}
|
||||||
|
anon := buildProbeNodeView(src, false)
|
||||||
|
if anon["country"] != "US" {
|
||||||
|
t.Errorf("anonymous country = %v, want US", anon["country"])
|
||||||
|
}
|
||||||
|
admin := buildProbeNodeView(src, true)
|
||||||
|
if admin["country"] != "US" || admin["country_auto"] != "US" || admin["country_pin"] != "" {
|
||||||
|
t.Errorf("admin pair = (%v, %v, pin %v), want (US, US, empty)",
|
||||||
|
admin["country"], admin["country_auto"], admin["country_pin"])
|
||||||
|
}
|
||||||
|
src.meta.CountryPin = "DE"
|
||||||
|
if got := buildProbeNodeView(src, false)["country"]; got != "DE" {
|
||||||
|
t.Errorf("pinned country = %v, want DE", got)
|
||||||
|
}
|
||||||
|
if got := buildProbeNodeView(src, true)["country_auto"]; got != "US" {
|
||||||
|
t.Errorf("country_auto under a pin = %v, want US", got)
|
||||||
|
}
|
||||||
|
// A node with no public address keeps the pre-flag empty country.
|
||||||
|
src = probeNodeSource{name: "dark", agent: model.AgentNode{IPv4: "192.168.0.9"}, meta: probe.Meta{Public: true}}
|
||||||
|
if got := buildProbeNodeView(src, false)["country"]; got != "" {
|
||||||
|
t.Errorf("private node country = %v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenGeoIPFileValidation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
bad := filepath.Join(dir, "bad.mmdb")
|
||||||
|
if err := os.WriteFile(bad, []byte("this is not an mmdb"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := openGeoIPFile(bad); err == nil {
|
||||||
|
t.Fatal("garbage file opened as a country database")
|
||||||
|
}
|
||||||
|
good := filepath.Join(dir, "good.mmdb")
|
||||||
|
if err := os.WriteFile(good, geoipCountryEmbedded, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r, err := openGeoIPFile(good)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("embedded bytes as a file rejected: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = r.Close() }()
|
||||||
|
db := &geoipDB{reader: r, cache: map[string]string{}}
|
||||||
|
if got := db.lookup("223.5.5.5"); got != "CN" {
|
||||||
|
t.Errorf("file-backed lookup = %q, want CN", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the weekly updater's download/validate/replace core ----
|
||||||
|
|
||||||
|
func TestDownloadGeoIPCountryUpdates(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("ETag", `"db-v1"`)
|
||||||
|
_, _ = w.Write(geoipCountryEmbedded)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
dest := filepath.Join(t.TempDir(), "geoip-country.mmdb")
|
||||||
|
res, etag, err := downloadGeoIPCountry(srv.Client(), srv.URL, dest, "", time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("download: %v", err)
|
||||||
|
}
|
||||||
|
if res != geoipFetchUpdated || etag != `"db-v1"` {
|
||||||
|
t.Fatalf("got (res %v, etag %q), want (updated, \"db-v1\")", res, etag)
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(dest)
|
||||||
|
if err != nil || fi.Size() != int64(len(geoipCountryEmbedded)) {
|
||||||
|
t.Fatalf("dest stat = %v (err %v), want %d bytes", fi, err, len(geoipCountryEmbedded))
|
||||||
|
}
|
||||||
|
// The replaced file is a working database.
|
||||||
|
r, err := openGeoIPFile(dest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replaced file will not open: %v", err)
|
||||||
|
}
|
||||||
|
_ = r.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadGeoIPCountryNotModified(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("If-None-Match") == `"db-v1"` || r.Header.Get("If-Modified-Since") != "" {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(geoipCountryEmbedded)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "geoip-country.mmdb")
|
||||||
|
if err := os.WriteFile(dest, []byte("ORIGINAL"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// ETag path.
|
||||||
|
res, _, err := downloadGeoIPCountry(srv.Client(), srv.URL, dest, `"db-v1"`, time.Time{})
|
||||||
|
if err != nil || res != geoipFetchUnchanged {
|
||||||
|
t.Fatalf("etag path: got (res %v, err %v), want unchanged", res, err)
|
||||||
|
}
|
||||||
|
// If-Modified-Since path.
|
||||||
|
res, _, err = downloadGeoIPCountry(srv.Client(), srv.URL, dest, "", time.Now())
|
||||||
|
if err != nil || res != geoipFetchUnchanged {
|
||||||
|
t.Fatalf("modified-since path: got (res %v, err %v), want unchanged", res, err)
|
||||||
|
}
|
||||||
|
body, _ := os.ReadFile(dest)
|
||||||
|
if string(body) != "ORIGINAL" {
|
||||||
|
t.Fatalf("dest rewritten on a 304: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadGeoIPCountryRejectsBad(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "geoip-country.mmdb")
|
||||||
|
if err := os.WriteFile(dest, []byte("ORIGINAL"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertKept := func(name string) {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := os.ReadFile(dest)
|
||||||
|
if string(body) != "ORIGINAL" {
|
||||||
|
t.Fatalf("%s: dest replaced despite the failure: %q", name, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
garbage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("definitely not an mmdb, just bytes"))
|
||||||
|
}))
|
||||||
|
defer garbage.Close()
|
||||||
|
if _, _, err := downloadGeoIPCountry(garbage.Client(), garbage.URL, dest, "", time.Time{}); err == nil {
|
||||||
|
t.Fatal("garbage download accepted")
|
||||||
|
}
|
||||||
|
assertKept("garbage")
|
||||||
|
|
||||||
|
broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer broken.Close()
|
||||||
|
if _, _, err := downloadGeoIPCountry(broken.Client(), broken.URL, dest, "", time.Time{}); err == nil {
|
||||||
|
t.Fatal("500 download accepted")
|
||||||
|
}
|
||||||
|
assertKept("500")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user