| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "net" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/geo" |
| 17 | "github.com/carlosframework/platform/internal/geoip" |
| 18 | "github.com/carlosframework/platform/internal/metrics" |
| 19 | "github.com/carlosframework/platform/internal/release" |
| 20 | ) |
| 21 | |
| 22 | func geoFixture(t *testing.T, name string) []byte { |
| 23 | b, err := os.ReadFile(filepath.Join("..", "geo", "testdata", name)) |
| 24 | if err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | return b |
| 28 | } |
| 29 | |
| 30 | // publishFixture writes blob + pointer the way the console does. |
| 31 | func publishFixture(t *testing.T, s release.Store, body []byte, dbType string, build time.Time) geoip.Pointer { |
| 32 | t.Helper() |
| 33 | sum := sha256.Sum256(body) |
| 34 | sha := hex.EncodeToString(sum[:]) |
| 35 | ctx := context.Background() |
| 36 | if _, err := s.Put(ctx, geoip.BlobKey(sha), body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| 37 | t.Fatal(err) // an existing blob is the same bytes: content-addressed |
| 38 | } |
| 39 | p := geoip.Pointer{V: 1, SHA256: sha, Key: geoip.BlobKey(sha), Size: int64(len(body)), |
| 40 | DatabaseType: dbType, BuildEpoch: build.Unix(), Origin: "manual"} |
| 41 | raw, _ := json.Marshal(p) |
| 42 | _, etag, _ := s.Get(ctx, geoip.PointerKey) |
| 43 | if _, err := s.Put(ctx, geoip.PointerKey, raw, etag); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | return p |
| 47 | } |
| 48 | |
| 49 | var testMirrorStoreDir = map[release.Store]string{} |
| 50 | |
| 51 | func newTestMirror(t *testing.T) (*geoMirror, release.Store, string) { |
| 52 | root := t.TempDir() |
| 53 | s := release.NewDirStore(root) |
| 54 | testMirrorStoreDir[s] = root |
| 55 | dir := filepath.Join(t.TempDir(), "carlos", "geoip") |
| 56 | return newGeoMirror(s, dir, geo.NewSwappable()), s, dir |
| 57 | } |
| 58 | |
| 59 | func lookupGB(m *geoMirror) bool { |
| 60 | snap := m.sw.Lookup(net.ParseIP("81.2.69.142")) |
| 61 | return snap.Found && snap.Result.Country == "GB" |
| 62 | } |
| 63 | |
| 64 | func TestMirrorInstallsAPublishedDatabase(t *testing.T) { |
| 65 | m, s, dir := newTestMirror(t) |
| 66 | p := publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| 67 | if err := m.Poll(context.Background()); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | if !lookupGB(m) { |
| 71 | t.Fatal("not serving after poll") |
| 72 | } |
| 73 | if _, err := os.Stat(filepath.Join(dir, p.SHA256+".mmdb")); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | if st := m.State(); st.SHA256 != p.SHA256 || st.Type != "GeoIP2-City" { |
| 77 | t.Fatalf("state = %+v", st) |
| 78 | } |
| 79 | fi, _ := os.Stat(dir) |
| 80 | if fi.Mode().Perm() != 0o755 { |
| 81 | t.Fatalf("dir mode = %v, want 0755 (the console reads it)", fi.Mode().Perm()) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func TestMirrorBootsFromTheLocalGenerationWithoutTheBucket(t *testing.T) { |
| 86 | m, s, dir := newTestMirror(t) |
| 87 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| 88 | if err := m.Poll(context.Background()); err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | // A fresh process, a dead bucket. |
| 92 | m2 := newGeoMirror(release.NewDirStore(filepath.Join(t.TempDir(), "empty")), dir, geo.NewSwappable()) |
| 93 | m2.Boot() |
| 94 | if !lookupGB(m2) { |
| 95 | t.Fatal("boot did not serve the local generation") |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestMirrorBootWithCurrentNamingAMissingFile(t *testing.T) { |
| 100 | m, _, dir := newTestMirror(t) |
| 101 | os.MkdirAll(dir, 0o755) |
| 102 | os.WriteFile(filepath.Join(dir, "current"), []byte(`{"sha256":"`+strings.Repeat("d", 64)+`","database_type":"X","build_epoch":1}`), 0o644) |
| 103 | m.Boot() |
| 104 | if snap := m.sw.Lookup(net.ParseIP("81.2.69.142")); snap.Loaded { |
| 105 | t.Fatal("loaded something from a dangling current") |
| 106 | } |
| 107 | if _, err := os.Stat(filepath.Join(dir, "current")); !os.IsNotExist(err) { |
| 108 | t.Fatal("dangling current was not removed") |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestMirrorRejectsADigestMismatchOnce(t *testing.T) { |
| 113 | m, s, _ := newTestMirror(t) |
| 114 | p := publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| 115 | // Corrupt the blob after publishing. |
| 116 | os.WriteFile(filepath.Join(testMirrorStoreDir[s], p.Key), append(geoFixture(t, "GeoIP2-City-Test.mmdb")[:p.Size-1], 'x'), 0o644) |
| 117 | if err := m.Poll(context.Background()); err == nil { |
| 118 | t.Fatal("a corrupt blob installed") |
| 119 | } |
| 120 | if m.sw.Lookup(net.ParseIP("81.2.69.142")).Loaded { |
| 121 | t.Fatal("serving a corrupt blob") |
| 122 | } |
| 123 | if err := m.Poll(context.Background()); err != nil { |
| 124 | t.Fatalf("second poll of the same rejected sha should be a quiet no-op, got %v", err) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | func TestMirrorOffClearsEverything(t *testing.T) { |
| 129 | m, s, dir := newTestMirror(t) |
| 130 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| 131 | m.Poll(context.Background()) |
| 132 | s.Delete(context.Background(), geoip.PointerKey) |
| 133 | if err := m.Poll(context.Background()); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | if m.sw.Lookup(net.ParseIP("81.2.69.142")).Loaded { |
| 137 | t.Fatal("still serving after off") |
| 138 | } |
| 139 | if matches, _ := filepath.Glob(filepath.Join(dir, "*.mmdb")); len(matches) != 0 { |
| 140 | t.Fatalf("generations left: %v", matches) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestMirrorNeverInstallsAnExpiredGeoLite(t *testing.T) { |
| 145 | m, s, _ := newTestMirror(t) |
| 146 | now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) |
| 147 | m.now = func() time.Time { return now } |
| 148 | // The fixture's own metadata says GeoIP2-City (no expiry), so make the |
| 149 | // POINTER claim GeoLite2 with an old build: expired before download. |
| 150 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoLite2-City", now.Add(-31*24*time.Hour)) |
| 151 | if err := m.Poll(context.Background()); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | if m.sw.Lookup(net.ParseIP("81.2.69.142")).Loaded { |
| 155 | t.Fatal("installed an expired pointer") |
| 156 | } |
| 157 | // Repeated polls of the same unmarked expired pointer stay quiet. |
| 158 | if err := m.Poll(context.Background()); err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func TestMirrorExpiryTickRemovesOnlyTheCurrentExpiredGeneration(t *testing.T) { |
| 164 | m, s, _ := newTestMirror(t) |
| 165 | now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) |
| 166 | m.now = func() time.Time { return now } |
| 167 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", now) |
| 168 | m.Poll(context.Background()) |
| 169 | // Pretend what is loaded is a GeoLite2 built 31 days ago. |
| 170 | m.genMu.Lock() |
| 171 | m.loadedType, m.loadedBuild = "GeoLite2-City", now.Add(-31*24*time.Hour) |
| 172 | m.genMu.Unlock() |
| 173 | m.ExpireTick() |
| 174 | if m.sw.Lookup(net.ParseIP("81.2.69.142")).Loaded { |
| 175 | t.Fatal("expired generation still serving") |
| 176 | } |
| 177 | // A fresh, non-expiring install afterwards is untouched by later ticks. |
| 178 | publishFixture(t, s, geoFixture(t, "GeoIP2-Country-Test.mmdb"), "GeoIP2-Country", now) |
| 179 | m.Poll(context.Background()) |
| 180 | m.ExpireTick() |
| 181 | if !lookupGB(m) { |
| 182 | t.Fatal("a later tick removed a fresh generation") |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func TestMirrorRefusesAnOversizeBlob(t *testing.T) { |
| 187 | m, s, dir := newTestMirror(t) |
| 188 | p := publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| 189 | m.maxBytes = p.Size - 1 |
| 190 | if err := m.Poll(context.Background()); err == nil { |
| 191 | t.Fatal("oversize installed") |
| 192 | } |
| 193 | if matches, _ := filepath.Glob(filepath.Join(dir, ".incoming-*")); len(matches) != 0 { |
| 194 | t.Fatalf("incoming left: %v", matches) |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | // Plan review R1-7: a pointer this binary cannot read turns GeoIP off. |
| 199 | func TestMirrorUnknownPointerVersionTurnsOff(t *testing.T) { |
| 200 | m, s, _ := newTestMirror(t) |
| 201 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| 202 | if err := m.Poll(context.Background()); err != nil || !lookupGB(m) { |
| 203 | t.Fatalf("setup: %v", err) |
| 204 | } |
| 205 | ctx := context.Background() |
| 206 | raw, etag, _ := s.Get(ctx, geoip.PointerKey) |
| 207 | var p map[string]any |
| 208 | json.Unmarshal(raw, &p) |
| 209 | p["v"] = 2 |
| 210 | raw2, _ := json.Marshal(p) |
| 211 | if _, err := s.Put(ctx, geoip.PointerKey, raw2, etag); err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | if err := m.Poll(ctx); err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | if m.sw.Lookup(net.ParseIP("81.2.69.142")).Loaded { |
| 218 | t.Fatal("still serving under a pointer version this binary cannot read") |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | // Plan review R1-8: a rejection lasts only while the pointer names that sha. |
| 223 | func TestMirrorRetriesARejectedShaAfterThePointerMoves(t *testing.T) { |
| 224 | m, s, _ := newTestMirror(t) |
| 225 | ctx := context.Background() |
| 226 | city := geoFixture(t, "GeoIP2-City-Test.mmdb") |
| 227 | b := publishFixture(t, s, city, "GeoIP2-City", time.Now()) |
| 228 | good := append([]byte(nil), city...) |
| 229 | os.WriteFile(filepath.Join(testMirrorStoreDir[s], b.Key), append(city[:len(city)-1], 'x'), 0o644) // corrupt B |
| 230 | if err := m.Poll(ctx); err == nil { |
| 231 | t.Fatal("corrupt B installed") |
| 232 | } |
| 233 | publishFixture(t, s, geoFixture(t, "GeoIP2-Country-Test.mmdb"), "GeoIP2-Country", time.Now()) // A |
| 234 | if err := m.Poll(ctx); err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | os.WriteFile(filepath.Join(testMirrorStoreDir[s], b.Key), good, 0o644) // B repaired |
| 238 | raw, _ := json.Marshal(b) |
| 239 | _, etag, _ := s.Get(ctx, geoip.PointerKey) |
| 240 | if _, err := s.Put(ctx, geoip.PointerKey, raw, etag); err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | if err := m.Poll(ctx); err != nil { |
| 244 | t.Fatalf("repaired B after the pointer moved away and back: %v", err) |
| 245 | } |
| 246 | if st := m.State(); st.SHA256 != b.SHA256 { |
| 247 | t.Fatalf("loaded %s, want repaired B %s", st.SHA256, b.SHA256) |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | // Plan review R1-3: an expiry tick during a replacement download must not |
| 252 | // unlink the download. |
| 253 | func TestMirrorExpiryDuringADownloadKeepsTheDownload(t *testing.T) { |
| 254 | m, s, _ := newTestMirror(t) |
| 255 | now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) |
| 256 | m.now = func() time.Time { return now } |
| 257 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", now) |
| 258 | m.Poll(context.Background()) |
| 259 | m.genMu.Lock() |
| 260 | m.loadedType, m.loadedBuild = "GeoLite2-City", now.Add(-31*24*time.Hour) // A is now expired |
| 261 | m.genMu.Unlock() |
| 262 | publishFixture(t, s, geoFixture(t, "GeoIP2-Country-Test.mmdb"), "GeoIP2-Country", now) // B |
| 263 | downloadHook = func() { m.ExpireTick() } |
| 264 | t.Cleanup(func() { downloadHook = nil }) |
| 265 | if err := m.Poll(context.Background()); err != nil { |
| 266 | t.Fatalf("replacement broken by a concurrent expiry: %v", err) |
| 267 | } |
| 268 | if !lookupGB(m) { |
| 269 | t.Fatal("B not serving") |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | // Plan review R1-4: a directory fsync failing after `current` is renamed |
| 274 | // must not delete the generation `current` names. |
| 275 | func TestMirrorSyncFailureAfterRenameKeepsTheGeneration(t *testing.T) { |
| 276 | m, s, dir := newTestMirror(t) |
| 277 | syncDirFn = func(string) error { return errors.New("injected") } |
| 278 | t.Cleanup(func() { syncDirFn = syncDir }) |
| 279 | p := publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| 280 | if err := m.Poll(context.Background()); err != nil { |
| 281 | t.Fatalf("poll: %v", err) |
| 282 | } |
| 283 | if _, err := os.Stat(filepath.Join(dir, p.SHA256+".mmdb")); err != nil { |
| 284 | t.Fatal("the generation current names was deleted") |
| 285 | } |
| 286 | // A second install while the first rename is still not durable keeps |
| 287 | // BOTH generations: a crash could bring back either `current` (code |
| 288 | // review R1-4). |
| 289 | q := publishFixture(t, s, geoFixture(t, "GeoIP2-Country-Test.mmdb"), "GeoIP2-Country", time.Now()) |
| 290 | if err := m.Poll(context.Background()); err != nil { |
| 291 | t.Fatal(err) |
| 292 | } |
| 293 | for _, sha := range []string{p.SHA256, q.SHA256} { |
| 294 | if _, err := os.Stat(filepath.Join(dir, sha+".mmdb")); err != nil { |
| 295 | t.Fatalf("generation %s deleted while the selection was not durable", sha[:8]) |
| 296 | } |
| 297 | } |
| 298 | syncDirFn = syncDir |
| 299 | m2 := newGeoMirror(release.NewDirStore(t.TempDir()), dir, geo.NewSwappable()) |
| 300 | m2.Boot() |
| 301 | if !lookupGB(m2) { |
| 302 | t.Fatal("restart after the failed sync did not recover the generation") |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | // Codex R8-2: an off that lands during a download takes effect at the end |
| 307 | // of that pass; the superseded download is never installed. |
| 308 | func TestMirrorOffDuringADownloadNeverInstalls(t *testing.T) { |
| 309 | m, s, dir := newTestMirror(t) |
| 310 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| 311 | downloadHook = func() { s.Delete(context.Background(), geoip.PointerKey) } // off, mid-download |
| 312 | t.Cleanup(func() { downloadHook = nil }) |
| 313 | if err := m.Poll(context.Background()); err != nil { |
| 314 | t.Fatal(err) |
| 315 | } |
| 316 | if m.sw.Lookup(net.ParseIP("81.2.69.142")).Loaded { |
| 317 | t.Fatal("a download superseded by off was installed") |
| 318 | } |
| 319 | if matches, _ := filepath.Glob(filepath.Join(dir, "*.mmdb")); len(matches) != 0 { |
| 320 | t.Fatalf("generations left: %v", matches) |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | // ...and a newer publish during a download installs the newer one in the |
| 325 | // same pass. |
| 326 | func TestMirrorNewerPublishDuringADownloadInstallsTheNewer(t *testing.T) { |
| 327 | m, s, _ := newTestMirror(t) |
| 328 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| 329 | var newer geoip.Pointer |
| 330 | downloadHook = func() { |
| 331 | downloadHook = nil // only the first download is interrupted |
| 332 | newer = publishFixture(t, s, geoFixture(t, "GeoIP2-Country-Test.mmdb"), "GeoIP2-Country", time.Now()) |
| 333 | } |
| 334 | t.Cleanup(func() { downloadHook = nil }) |
| 335 | if err := m.Poll(context.Background()); err != nil { |
| 336 | t.Fatal(err) |
| 337 | } |
| 338 | if st := m.State(); st.SHA256 != newer.SHA256 { |
| 339 | t.Fatalf("loaded %s, want the newer %s", st.SHA256, newer.SHA256) |
| 340 | } |
| 341 | } |
| 342 | |
| 343 | // Plan 2 Task 4 / spec §10.4: the geoip rule's reason codes. |
| 344 | func TestMirrorAlertCodes(t *testing.T) { |
| 345 | now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) |
| 346 | m, s, _ := newTestMirror(t) |
| 347 | m.now = func() time.Time { return now } |
| 348 | ctx := context.Background() |
| 349 | |
| 350 | if c, _ := m.AlertCode(now, "box"); c != GeoAlertNone { |
| 351 | t.Fatalf("nothing published = %v", c) |
| 352 | } |
| 353 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", now) |
| 354 | m.Poll(ctx) |
| 355 | // The fixture was built 2026-02-04: more than 45 days before now. |
| 356 | if c, _ := m.AlertCode(now, "box"); c != GeoAlertStale { |
| 357 | t.Fatalf("an old database = %v, want stale", c) |
| 358 | } |
| 359 | if c, _ := m.AlertCode(time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC), "box"); c != GeoAlertNone { |
| 360 | t.Fatalf("a fresh database = %v", c) |
| 361 | } |
| 362 | // GeoLite2 at 22 days: expiring wins over stale's absence. |
| 363 | m.genMu.Lock() |
| 364 | m.loadedType, m.loadedBuild = "GeoLite2-City", now.Add(-22*24*time.Hour) |
| 365 | m.genMu.Unlock() |
| 366 | if c, _ := m.AlertCode(now, "box"); c != GeoAlertExpiring { |
| 367 | t.Fatalf("GeoLite2 at 22 days = %v, want expiring", c) |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | // Codex R11-1: an expired pointer keeps the alert firing after the reader |
| 372 | // is dropped and before the console marks the bucket; off clears it. |
| 373 | func TestMirrorAlertSurvivesExpiryUntilOff(t *testing.T) { |
| 374 | now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) |
| 375 | m, s, _ := newTestMirror(t) |
| 376 | m.now = func() time.Time { return now } |
| 377 | ctx := context.Background() |
| 378 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoLite2-City", now.Add(-31*24*time.Hour)) // unmarked, past D1 |
| 379 | m.Poll(ctx) |
| 380 | if c, _ := m.AlertCode(now, "box"); c != GeoAlertExpired { |
| 381 | t.Fatalf("expired, unmarked = %v, want expired", c) |
| 382 | } |
| 383 | // A restart with the same unmarked pointer: still expired. |
| 384 | m2 := newGeoMirror(s, m.dir, geo.NewSwappable()) |
| 385 | m2.now = m.now |
| 386 | m2.Boot() |
| 387 | m2.Poll(ctx) |
| 388 | if c, _ := m2.AlertCode(now, "box"); c != GeoAlertExpired { |
| 389 | t.Fatalf("after restart = %v, want expired", c) |
| 390 | } |
| 391 | // A fresh publish clears it. |
| 392 | publishFixture(t, s, geoFixture(t, "GeoIP2-Country-Test.mmdb"), "GeoIP2-Country", now) |
| 393 | m.Poll(ctx) |
| 394 | if c, _ := m.AlertCode(time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC), "box"); c == GeoAlertExpired { |
| 395 | t.Fatal("a fresh publish left the expired reason") |
| 396 | } |
| 397 | // Expired again, then off: off clears every pointer reason. |
| 398 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoLite2-City", now.Add(-31*24*time.Hour)) |
| 399 | m.Poll(ctx) |
| 400 | s.Delete(ctx, geoip.PointerKey) |
| 401 | m.Poll(ctx) |
| 402 | if c, _ := m.AlertCode(now, "box"); c != GeoAlertNone { |
| 403 | t.Fatalf("after off = %v, want none", c) |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | func TestMirrorAlertNotLoadedAfterAnHour(t *testing.T) { |
| 408 | now := time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC) |
| 409 | m, s, _ := newTestMirror(t) |
| 410 | clock := now |
| 411 | m.now = func() time.Time { return clock } |
| 412 | p := publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", now) |
| 413 | os.WriteFile(filepath.Join(testMirrorStoreDir[s], p.Key), []byte("corrupt"), 0o644) |
| 414 | m.Poll(context.Background()) |
| 415 | if c, _ := m.AlertCode(clock.Add(59*time.Minute), "box"); c != GeoAlertNone { |
| 416 | t.Fatalf("not loaded for 59 minutes = %v", c) |
| 417 | } |
| 418 | if c, _ := m.AlertCode(clock.Add(61*time.Minute), "box"); c != GeoAlertNotLoaded { |
| 419 | t.Fatalf("not loaded for 61 minutes = %v, want not loaded", c) |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | func TestMirrorAlertFeedReasonOnlyOnTheConsoleBox(t *testing.T) { |
| 424 | now := time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC) |
| 425 | m, s, _ := newTestMirror(t) |
| 426 | m.now = func() time.Time { return now } |
| 427 | raw, _ := json.Marshal(geoip.FeedStatus{V: 1, Feed: "dbip-city-lite", Box: "console-box", ConsecutiveNotFound: 7}) |
| 428 | if _, err := s.Put(context.Background(), geoip.FeedKey, raw, ""); err != nil { |
| 429 | t.Fatal(err) |
| 430 | } |
| 431 | m.Poll(context.Background()) // no pointer at all: a fresh deployment |
| 432 | if c, _ := m.AlertCode(now, "console-box"); c != GeoAlertFeed { |
| 433 | t.Fatalf("console box = %v, want feed", c) |
| 434 | } |
| 435 | if c, _ := m.AlertCode(now, "other-box"); c != GeoAlertNone { |
| 436 | t.Fatalf("another box = %v, want none", c) |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | // Code review R2 on R1-4: while directory syncs keep failing, every |
| 441 | // generation a crash could bring back is kept. |
| 442 | func TestMirrorKeepsEveryUnsyncedGeneration(t *testing.T) { |
| 443 | m, s, dir := newTestMirror(t) |
| 444 | a := publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| 445 | m.Poll(context.Background()) // A, durable |
| 446 | syncDirFn = func(string) error { return errors.New("injected") } |
| 447 | t.Cleanup(func() { syncDirFn = syncDir }) |
| 448 | b := publishFixture(t, s, geoFixture(t, "GeoIP2-Country-Test.mmdb"), "GeoIP2-Country", time.Now()) |
| 449 | m.Poll(context.Background()) |
| 450 | c := publishFixture(t, s, geoFixture(t, "MaxMind-DB-test-ipv4-24.mmdb"), "Test", time.Now()) |
| 451 | m.Poll(context.Background()) // C will be refused? the mirror does not probe; it installs whatever verifies |
| 452 | for _, sha := range []string{a.SHA256, b.SHA256} { |
| 453 | if _, err := os.Stat(filepath.Join(dir, sha+".mmdb")); err != nil { |
| 454 | t.Fatalf("generation %s deleted while syncs keep failing", sha[:8]) |
| 455 | } |
| 456 | } |
| 457 | _ = c |
| 458 | syncDirFn = syncDir |
| 459 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now().Add(time.Second)) |
| 460 | } |
| 461 | |
| 462 | // Code review R2-3: a read blip keeps the last known feed state. |
| 463 | func TestMirrorFeedStateSurvivesAReadBlip(t *testing.T) { |
| 464 | now := time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC) |
| 465 | m, s, _ := newTestMirror(t) |
| 466 | m.now = func() time.Time { return now } |
| 467 | raw, _ := json.Marshal(geoip.FeedStatus{V: 1, Feed: "f", Box: "console-box", ConsecutiveFailures: 7}) |
| 468 | s.Put(context.Background(), geoip.FeedKey, raw, "") |
| 469 | m.Poll(context.Background()) |
| 470 | _, etag, _ := s.Get(context.Background(), geoip.FeedKey) |
| 471 | s.Put(context.Background(), geoip.FeedKey, []byte("garbled"), etag) |
| 472 | m.Poll(context.Background()) |
| 473 | if c, _ := m.AlertCode(now, "console-box"); c != GeoAlertFeed { |
| 474 | t.Fatalf("a garbled read cleared the feed reason: %v", c) |
| 475 | } |
| 476 | s.Delete(context.Background(), geoip.FeedKey) |
| 477 | m.Poll(context.Background()) |
| 478 | if c, _ := m.AlertCode(now, "console-box"); c != GeoAlertNone { |
| 479 | t.Fatalf("a deleted feed.json must clear it: %v", c) |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | // Code review R2-4: an unusable local directory still lets the alert see |
| 484 | // the published pointer. |
| 485 | func TestMirrorUnusableDirectoryStillTracksThePointer(t *testing.T) { |
| 486 | now := time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC) |
| 487 | s := release.NewDirStore(t.TempDir()) |
| 488 | blocker := filepath.Join(t.TempDir(), "file") |
| 489 | os.WriteFile(blocker, []byte("x"), 0o644) |
| 490 | m := newGeoMirror(s, filepath.Join(blocker, "geoip"), geo.NewSwappable()) // a path under a file: MkdirAll fails |
| 491 | m.now = func() time.Time { return now } |
| 492 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", now) |
| 493 | if err := m.Poll(context.Background()); err == nil { |
| 494 | t.Fatal("an unusable directory reported success") |
| 495 | } |
| 496 | if c, _ := m.AlertCode(now.Add(61*time.Minute), "box"); c != GeoAlertNotLoaded { |
| 497 | t.Fatalf("code = %v, want not loaded", c) |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | // Code review R3 on R1-4: an install error path never deletes a generation |
| 502 | // a crash could bring back. |
| 503 | func TestRemoveGenerationKeepsRetainedOnes(t *testing.T) { |
| 504 | m, _, dir := newTestMirror(t) |
| 505 | os.MkdirAll(dir, 0o755) |
| 506 | a, b, c := strings.Repeat("a", 64), strings.Repeat("b", 64), strings.Repeat("c", 64) |
| 507 | for _, sha := range []string{a, b, c} { |
| 508 | os.WriteFile(filepath.Join(dir, sha+".mmdb"), []byte("x"), 0o644) |
| 509 | } |
| 510 | m.loadedSHA, m.unsynced = a, map[string]geoCurrent{b: {SHA256: b}} |
| 511 | for _, sha := range []string{a, b, c} { |
| 512 | m.removeGenerationLocked(sha) |
| 513 | } |
| 514 | for sha, want := range map[string]bool{a: true, b: true, c: false} { |
| 515 | if _, err := os.Stat(filepath.Join(dir, sha+".mmdb")); (err == nil) != want { |
| 516 | t.Errorf("%s present=%v, want %v", sha[:1], err == nil, want) |
| 517 | } |
| 518 | } |
| 519 | } |
| 520 | |
| 521 | // Code review R4-2: a cold mirror has no reading until it has read both |
| 522 | // the pointer and the feed status. |
| 523 | func TestMirrorAlertUnknownUntilStateIsRead(t *testing.T) { |
| 524 | m, s, _ := newTestMirror(t) |
| 525 | if _, known := m.AlertCode(time.Now(), "box"); known { |
| 526 | t.Fatal("a cold mirror claimed a reading") |
| 527 | } |
| 528 | s.Put(context.Background(), geoip.FeedKey, []byte("garbled"), "") // feed unreadable |
| 529 | m.Poll(context.Background()) |
| 530 | if _, known := m.AlertCode(time.Now(), "box"); known { |
| 531 | t.Fatal("an unreadable feed status gave a reading") |
| 532 | } |
| 533 | _, etag, _ := s.Get(context.Background(), geoip.FeedKey) |
| 534 | _ = etag |
| 535 | s.Delete(context.Background(), geoip.FeedKey) |
| 536 | m.Poll(context.Background()) |
| 537 | if c, known := m.AlertCode(time.Now(), "box"); !known || c != GeoAlertNone { |
| 538 | t.Fatalf("after both reads = (%v, %v)", c, known) |
| 539 | } |
| 540 | } |
| 541 | |
| 542 | // Code review R5-1: a confirmed expiry is reported even when feed.json is |
| 543 | // unreadable, through the evaluator. |
| 544 | func TestMirrorExpiryReportedWithAnUnreadableFeed(t *testing.T) { |
| 545 | now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) |
| 546 | m, s, _ := newTestMirror(t) |
| 547 | m.now = func() time.Time { return now } |
| 548 | s.Put(context.Background(), geoip.FeedKey, []byte("garbled"), "") |
| 549 | publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoLite2-City", now.Add(-31*24*time.Hour)) |
| 550 | m.Poll(context.Background()) |
| 551 | c, known := m.AlertCode(now, "box") |
| 552 | if !known || c != GeoAlertExpired { |
| 553 | t.Fatalf("expired with an unreadable feed = (%v, %v), want (3, true)", c, known) |
| 554 | } |
| 555 | r := metrics.DefaultRules() |
| 556 | st := metrics.AlertState{V: 1, Box: "box"} |
| 557 | st, _ = metrics.Evaluate(r, st, metrics.Observation{At: now, GeoIP: &c}) |
| 558 | _, ev := metrics.Evaluate(r, st, metrics.Observation{At: now.Add(30 * time.Minute), GeoIP: &c}) |
| 559 | if len(ev) != 1 || ev[0].Value != GeoAlertExpired { |
| 560 | t.Fatalf("evaluator = %v", ev) |
| 561 | } |
| 562 | } |
| 563 | |
| 564 | // Code review R10-2: one failed sync during a GeoLite2 -> DB-IP move; the |
| 565 | // retained GeoLite2 copy goes once a later sync lands, or at its expiry. |
| 566 | func TestMirrorRetainedCopyIsCleanedOrExpired(t *testing.T) { |
| 567 | for _, mode := range []string{"sync recovers", "expiry"} { |
| 568 | now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) |
| 569 | m, s, dir := newTestMirror(t) |
| 570 | m.now = func() time.Time { return now } |
| 571 | a := publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", now) |
| 572 | m.Poll(context.Background()) |
| 573 | m.genMu.Lock() |
| 574 | m.loadedType, m.loadedBuild = "GeoLite2-City", now.Add(-10*24*time.Hour) // A is a GeoLite2, 10 days old |
| 575 | m.genMu.Unlock() |
| 576 | syncDirFn = func(string) error { return errors.New("injected") } |
| 577 | publishFixture(t, s, geoFixture(t, "GeoIP2-Country-Test.mmdb"), "GeoIP2-Country", now) |
| 578 | m.Poll(context.Background()) |
| 579 | if _, err := os.Stat(filepath.Join(dir, a.SHA256+".mmdb")); err != nil { |
| 580 | t.Fatalf("%s: A not retained", mode) |
| 581 | } |
| 582 | if mode == "sync recovers" { |
| 583 | syncDirFn = syncDir |
| 584 | m.Poll(context.Background()) // unchanged pointer |
| 585 | } else { |
| 586 | now = now.Add(21 * 24 * time.Hour) // A past its 30 days |
| 587 | m.ExpireTick() |
| 588 | syncDirFn = syncDir |
| 589 | } |
| 590 | if _, err := os.Stat(filepath.Join(dir, a.SHA256+".mmdb")); !os.IsNotExist(err) { |
| 591 | t.Fatalf("%s: A survived (%v)", mode, err) |
| 592 | } |
| 593 | } |
| 594 | syncDirFn = syncDir |
| 595 | } |
| 596 | |