| 1 | package geo |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "errors" |
| 6 | "log" |
| 7 | "math" |
| 8 | "net" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | ) |
| 15 | |
| 16 | func TestOpenMMDBRefusesNonDatabases(t *testing.T) { |
| 17 | dir := t.TempDir() |
| 18 | missing := filepath.Join(dir, "missing.mmdb") |
| 19 | junk := filepath.Join(dir, "junk.mmdb") |
| 20 | if err := os.WriteFile(junk, []byte("not a maxmind database"), 0o600); err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | city, err := os.ReadFile("testdata/GeoIP2-City-Test.mmdb") |
| 24 | if err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | truncated := filepath.Join(dir, "truncated.mmdb") |
| 28 | if err := os.WriteFile(truncated, city[:len(city)/3], 0o600); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | for _, p := range []string{missing, junk, truncated} { |
| 32 | if m, err := OpenMMDB(p); err == nil { |
| 33 | m.Close() |
| 34 | t.Errorf("OpenMMDB(%s) succeeded, want an error", filepath.Base(p)) |
| 35 | } |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | func TestMMDBCityRecordGivesItsLocation(t *testing.T) { |
| 40 | m, err := OpenMMDB("testdata/GeoIP2-City-Test.mmdb") |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | defer m.Close() |
| 45 | p, ok := m.Locate(net.ParseIP("81.2.69.142")) |
| 46 | if !ok || p.Lat < 51 || p.Lat > 52 { |
| 47 | t.Fatalf("81.2.69.142 = (%v, %v), want a London point", p, ok) |
| 48 | } |
| 49 | // Review Focus 2: an IPv4-mapped IPv6 address is the same client. |
| 50 | if mapped, ok := m.Locate(net.ParseIP("::ffff:81.2.69.142")); !ok || mapped != p { |
| 51 | t.Fatalf("::ffff:81.2.69.142 = (%v, %v), want %v", mapped, ok, p) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | func TestMMDBCountryOnlyRecordGivesTheCountryPoint(t *testing.T) { |
| 56 | m, err := OpenMMDB("testdata/GeoIP2-Country-Test.mmdb") |
| 57 | if err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | defer m.Close() |
| 61 | p, ok := m.Locate(net.ParseIP("81.2.69.142")) |
| 62 | gb, _ := CountryPoint("GB") |
| 63 | if !ok || p != gb { |
| 64 | t.Fatalf("country-only 81.2.69.142 = (%v, %v), want GB's point %v", p, ok, gb) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | // Review Focus 2 and spec §6.5: a public IPv6 address looked up in an |
| 69 | // IPv4-only database is a miss, and the reader's error — which contains |
| 70 | // the address — never reaches the log. |
| 71 | func TestMMDBLookupErrorsNeverLogTheAddress(t *testing.T) { |
| 72 | m, err := OpenMMDB("testdata/MaxMind-DB-test-ipv4-24.mmdb") |
| 73 | if err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | defer m.Close() |
| 77 | var buf bytes.Buffer |
| 78 | prev := log.Writer() |
| 79 | log.SetOutput(&buf) |
| 80 | defer log.SetOutput(prev) |
| 81 | lookupErrLast = lookupErrLast.AddDate(-1, 0, 0) // un-throttle |
| 82 | if _, ok := m.Locate(net.ParseIP("2001:4860::1")); ok { |
| 83 | t.Fatal("an IPv6 address located in an IPv4-only database") |
| 84 | } |
| 85 | if bytes.Contains(buf.Bytes(), []byte("2001:4860")) { |
| 86 | t.Fatalf("the address leaked into the log: %s", buf.String()) |
| 87 | } |
| 88 | if buf.Len() == 0 { |
| 89 | t.Fatal("a lookup failure must be logged (as a fixed category)") |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // Final review: a structurally valid database can still carry a malformed |
| 94 | // location. Nonfinite values and coordinates off the globe are a miss |
| 95 | // (ok=false), never a point that ranks regions from nowhere, and never a |
| 96 | // fall-through to the record's country either: a record that lies about |
| 97 | // one field is not trusted on the next. |
| 98 | func TestLocateRejectsMalformedCoordinates(t *testing.T) { |
| 99 | at := func(lat, lon float64) *MMDB { |
| 100 | return &MMDB{lookup: func(_ net.IP, result any) error { |
| 101 | rec := result.(*mmdbRecord) |
| 102 | rec.Location.Latitude, rec.Location.Longitude = &lat, &lon |
| 103 | rec.Country.ISOCode = "GB" |
| 104 | return nil |
| 105 | }} |
| 106 | } |
| 107 | bad := map[string][2]float64{ |
| 108 | "NaN latitude": {math.NaN(), 0}, |
| 109 | "NaN longitude": {0, math.NaN()}, |
| 110 | "+Inf latitude": {math.Inf(1), 0}, |
| 111 | "-Inf longitude": {0, math.Inf(-1)}, |
| 112 | "latitude above 90": {90.0001, 0}, |
| 113 | "latitude below -90": {-91, 0}, |
| 114 | "longitude above 180": {0, 180.5}, |
| 115 | "longitude below -180": {0, -181}, |
| 116 | } |
| 117 | for name, c := range bad { |
| 118 | if p, ok := at(c[0], c[1]).Locate(net.ParseIP("81.2.69.142")); ok { |
| 119 | t.Errorf("%s: located %v, want a miss", name, p) |
| 120 | } |
| 121 | } |
| 122 | // The edges of the globe are on it. |
| 123 | for name, c := range map[string][2]float64{ |
| 124 | "north pole": {90, 0}, "south pole": {-90, 0}, "antimeridian east": {0, 180}, "antimeridian west": {0, -180}, |
| 125 | } { |
| 126 | if p, ok := at(c[0], c[1]).Locate(net.ParseIP("81.2.69.142")); !ok || p != (Point{c[0], c[1]}) { |
| 127 | t.Errorf("%s: (%v, %v), want %v", name, p, ok, Point{c[0], c[1]}) |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // Review Focus 2: non-public addresses never reach the reader — proved |
| 133 | // through Locate itself, with a counting lookup in place of the reader. |
| 134 | func TestLocateNeverLooksUpNonPublicAddresses(t *testing.T) { |
| 135 | calls := 0 |
| 136 | m := &MMDB{lookup: func(net.IP, any) error { calls++; return errors.New("unused") }} |
| 137 | for _, s := range []string{ |
| 138 | "127.0.0.1", "::1", "10.1.2.3", "192.168.0.1", "172.16.0.1", |
| 139 | "100.64.0.1", "169.254.1.1", "fe80::1", "fd00::1", "0.0.0.0", "::", |
| 140 | } { |
| 141 | if _, ok := m.Locate(net.ParseIP(s)); ok { |
| 142 | t.Errorf("Locate(%s) answered", s) |
| 143 | } |
| 144 | } |
| 145 | if calls != 0 { |
| 146 | t.Fatalf("the reader was asked %d time(s) about non-public addresses", calls) |
| 147 | } |
| 148 | m.Locate(net.ParseIP("81.2.69.142")) |
| 149 | if calls != 1 { |
| 150 | t.Fatalf("a public address must reach the reader, calls = %d", calls) |
| 151 | } |
| 152 | var nilM *MMDB |
| 153 | if _, ok := nilM.Locate(net.ParseIP("81.2.69.142")); ok { |
| 154 | t.Error("a nil *MMDB located an address") |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func TestLookupCityGivesCountrySubdivisionAndPoint(t *testing.T) { |
| 159 | m, err := OpenMMDB("testdata/GeoIP2-City-Test.mmdb") |
| 160 | if err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | defer m.Close() |
| 164 | cases := map[string]Result{ |
| 165 | "81.2.69.142": {Country: "GB", Subdivision: "GB-ENG", HasPoint: true}, |
| 166 | "216.160.83.56": {Country: "US", Subdivision: "US-WA", HasPoint: true}, |
| 167 | "89.160.20.112": {Country: "SE", Subdivision: "SE-E", HasPoint: true}, |
| 168 | "2001:218::1": {Country: "JP", HasPoint: true}, // no subdivision in the fixture |
| 169 | } |
| 170 | for ip, want := range cases { |
| 171 | got, ok := m.Lookup(net.ParseIP(ip)) |
| 172 | if !ok || got.Country != want.Country || got.Subdivision != want.Subdivision || got.HasPoint != want.HasPoint { |
| 173 | t.Errorf("Lookup(%s) = (%+v, %v), want %+v", ip, got, ok, want) |
| 174 | } |
| 175 | } |
| 176 | if got, _ := m.Lookup(net.ParseIP("81.2.69.142")); got.Point.Lat < 51 || got.Point.Lat > 52 { |
| 177 | t.Errorf("London point = %v", got.Point) |
| 178 | } |
| 179 | if m.Source() != "GeoIP2-City" { |
| 180 | t.Errorf("Source = %q", m.Source()) |
| 181 | } |
| 182 | if m.NodeCount() != 1547 { |
| 183 | t.Errorf("NodeCount = %d, want 1547 (FACTS.md)", m.NodeCount()) |
| 184 | } |
| 185 | if m.BuildEpoch().Year() < 2020 { |
| 186 | t.Errorf("BuildEpoch = %v", m.BuildEpoch()) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | func TestLookupCountryDatabaseHasNoPoint(t *testing.T) { |
| 191 | m, err := OpenMMDB("testdata/GeoIP2-Country-Test.mmdb") |
| 192 | if err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | defer m.Close() |
| 196 | got, ok := m.Lookup(net.ParseIP("81.2.69.142")) |
| 197 | if !ok || got.Country != "GB" || got.HasPoint || got.Subdivision != "" { |
| 198 | t.Fatalf("country-only = (%+v, %v), want GB with no point (never a centroid)", got, ok) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | func TestLookupNoCountryDatabaseMisses(t *testing.T) { |
| 203 | m, err := OpenMMDB("testdata/MaxMind-DB-test-ipv4-24.mmdb") |
| 204 | if err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | defer m.Close() |
| 208 | if got, ok := m.Lookup(net.ParseIP("81.2.69.142")); ok { |
| 209 | t.Fatalf("a database with no country or location answered %+v", got) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | func TestLookupFallsBackToRegisteredCountryButNotItsSubdivision(t *testing.T) { |
| 214 | m := &MMDB{lookup: func(_ net.IP, result any) error { |
| 215 | rec := result.(*mmdbRecord) |
| 216 | rec.RegisteredCountry.ISOCode = "IE" |
| 217 | rec.Subdivisions = append(rec.Subdivisions, struct { |
| 218 | ISOCode string `maxminddb:"iso_code"` |
| 219 | }{"L"}) |
| 220 | return nil |
| 221 | }} |
| 222 | got, ok := m.Lookup(net.ParseIP("81.2.69.142")) |
| 223 | if !ok || got.Country != "IE" || got.Subdivision != "" { |
| 224 | t.Fatalf("registered fallback = (%+v, %v), want IE with no subdivision", got, ok) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | func TestLookupPointWithoutCountrySucceeds(t *testing.T) { |
| 229 | lat, lon := 10.0, 20.0 |
| 230 | m := &MMDB{lookup: func(_ net.IP, result any) error { |
| 231 | rec := result.(*mmdbRecord) |
| 232 | rec.Location.Latitude, rec.Location.Longitude = &lat, &lon |
| 233 | return nil |
| 234 | }} |
| 235 | got, ok := m.Lookup(net.ParseIP("81.2.69.142")) |
| 236 | if !ok || got.Country != "" || !got.HasPoint || got.Point != (Point{10, 20}) { |
| 237 | t.Fatalf("point-only = (%+v, %v)", got, ok) |
| 238 | } |
| 239 | // Locate's existing rule: a valid point succeeds without a country. |
| 240 | if p, ok := m.Locate(net.ParseIP("81.2.69.142")); !ok || p != (Point{10, 20}) { |
| 241 | t.Fatalf("Locate point-only = (%v, %v)", p, ok) |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | func TestLocateRegisteredCountryFallbackUnchanged(t *testing.T) { |
| 246 | // Codex R1-9: a country with no representative point falls back to the |
| 247 | // registered country's point. "XK" has none in the table; "GB" does. |
| 248 | if _, ok := CountryPoint("XK"); ok { |
| 249 | t.Skip("XK gained a representative point; pick another code absent from the table") |
| 250 | } |
| 251 | m := &MMDB{lookup: func(_ net.IP, result any) error { |
| 252 | rec := result.(*mmdbRecord) |
| 253 | rec.Country.ISOCode, rec.RegisteredCountry.ISOCode = "XK", "GB" |
| 254 | return nil |
| 255 | }} |
| 256 | gb, _ := CountryPoint("GB") |
| 257 | if p, ok := m.Locate(net.ParseIP("81.2.69.142")); !ok || p != gb { |
| 258 | t.Fatalf("Locate = (%v, %v), want GB's point", p, ok) |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | func TestLookupRejectsMalformedCodes(t *testing.T) { |
| 263 | m := &MMDB{lookup: func(_ net.IP, result any) error { |
| 264 | rec := result.(*mmdbRecord) |
| 265 | rec.Country.ISOCode = "gb" // lower case is not ISO |
| 266 | rec.Subdivisions = append(rec.Subdivisions, struct { |
| 267 | ISOCode string `maxminddb:"iso_code"` |
| 268 | }{"TOOLONG"}) |
| 269 | return nil |
| 270 | }} |
| 271 | if got, ok := m.Lookup(net.ParseIP("81.2.69.142")); ok { |
| 272 | t.Fatalf("malformed codes answered %+v", got) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | func TestOpenMMDBTrustedOpensWithoutVerify(t *testing.T) { |
| 277 | m, err := OpenMMDBTrusted("testdata/GeoIP2-City-Test.mmdb") |
| 278 | if err != nil { |
| 279 | t.Fatal(err) |
| 280 | } |
| 281 | defer m.Close() |
| 282 | if r, ok := m.Lookup(net.ParseIP("81.2.69.142")); !ok || r.Country != "GB" { |
| 283 | t.Fatalf("trusted open lookup = (%+v, %v)", r, ok) |
| 284 | } |
| 285 | if _, err := OpenMMDBTrusted(filepath.Join(t.TempDir(), "missing.mmdb")); err == nil { |
| 286 | t.Fatal("a missing file opened") |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | func TestSourceIsSanitised(t *testing.T) { |
| 291 | m := &MMDB{source: sanitizeSource("GeoLite2-City\n<script>" + strings.Repeat("x", 100))} |
| 292 | if s := m.Source(); strings.ContainsAny(s, "\n<>") || len(s) > 64 { |
| 293 | t.Fatalf("Source = %q", s) |
| 294 | } |
| 295 | var nilM *MMDB |
| 296 | if nilM.Source() != "" || nilM.NodeCount() != 0 || !nilM.BuildEpoch().IsZero() { |
| 297 | t.Fatal("a nil MMDB must answer zero values") |
| 298 | } |
| 299 | _ = time.Now |
| 300 | } |
| 301 | |
| 302 | func TestSourceNeverSanitisesToEmpty(t *testing.T) { |
| 303 | if got := sanitizeSource("\n<>!!"); got != "mmdb" { |
| 304 | t.Fatalf("sanitizeSource of only discarded characters = %q, want mmdb", got) |
| 305 | } |
| 306 | if got := sanitizeSource(""); got != "mmdb" { |
| 307 | t.Fatalf("empty type = %q, want mmdb", got) |
| 308 | } |
| 309 | } |
| 310 | |