mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-24 21:17:31 +00:00
209 lines
6.9 KiB
Go
209 lines
6.9 KiB
Go
package geolite
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// newRefresherForTest returns a refresher wired to a service backed by a database file inside dir
|
|
func newRefresherForTest(t *testing.T, dir string, httpClient *http.Client) *refresher {
|
|
t.Helper()
|
|
|
|
svc := newServiceAtPathForTest(t, filepath.Join(dir, "GeoLite2-City.mmdb"))
|
|
|
|
return &refresher{
|
|
log: testLogger(),
|
|
service: svc,
|
|
httpClient: httpClient,
|
|
downloadURL: testDownloadURL,
|
|
watching: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// runRefresherForTest starts the refresher and waits until its watcher is established, so a change made afterwards is guaranteed to be noticed
|
|
// The refresher is stopped when the test ends
|
|
func runRefresherForTest(t *testing.T, r *refresher) context.Context {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- r.Run(ctx)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
require.NoError(t, <-done)
|
|
})
|
|
|
|
select {
|
|
case <-r.watching:
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("timed out waiting for the database watcher to start")
|
|
}
|
|
|
|
return ctx
|
|
}
|
|
|
|
func TestRefresherTimeUntilRefresh(t *testing.T) {
|
|
t.Run("missing database", func(t *testing.T) {
|
|
r := newRefresherForTest(t, t.TempDir(), nil)
|
|
require.Zero(t, r.timeUntilRefresh())
|
|
})
|
|
|
|
t.Run("fresh database", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
r := newRefresherForTest(t, dir, nil)
|
|
writeDatabaseFileForTest(t, r.service.dbPath, readTestDatabase(t))
|
|
|
|
// The wait is the remaining lifetime of the file, give or take the jitter
|
|
delay := r.timeUntilRefresh()
|
|
require.InDelta(t, databaseMaxAge, delay, float64(refreshJitter+time.Minute))
|
|
require.Positive(t, delay)
|
|
})
|
|
|
|
t.Run("stale database", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
r := newRefresherForTest(t, dir, nil)
|
|
writeDatabaseFileForTest(t, r.service.dbPath, readTestDatabase(t))
|
|
|
|
aged := time.Now().Add(-databaseMaxAge - time.Hour)
|
|
err := os.Chtimes(r.service.dbPath, aged, aged)
|
|
require.NoError(t, err)
|
|
|
|
require.Zero(t, r.timeUntilRefresh())
|
|
})
|
|
}
|
|
|
|
func TestRefresherRefresh(t *testing.T) {
|
|
database := readTestDatabase(t)
|
|
archive := buildTarGzForTest(t, map[string][]byte{"GeoLite2-City_20260101/" + databaseFileName: database})
|
|
httpClient, transport := newDownloadClientForTest(archive)
|
|
|
|
r := newRefresherForTest(t, t.TempDir(), httpClient)
|
|
|
|
// Nothing to look up against before the first refresh
|
|
country, _, err := r.service.GetLocationByIP(t.Context(), "81.2.69.142")
|
|
require.NoError(t, err)
|
|
require.Empty(t, country)
|
|
|
|
err = r.refresh(t.Context())
|
|
require.NoError(t, err)
|
|
require.Equal(t, int32(1), transport.requests.Load())
|
|
|
|
// The database is on disk, and the service is serving from it without waiting for the watcher
|
|
require.FileExists(t, r.service.dbPath)
|
|
country, city, err := r.service.GetLocationByIP(t.Context(), "81.2.69.142")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "United Kingdom", country)
|
|
require.Equal(t, "London", city)
|
|
|
|
// The refresh it just performed pushes the next one out by the full lifetime of the database
|
|
require.Positive(t, r.timeUntilRefresh())
|
|
}
|
|
|
|
func TestRefresherRefreshFailure(t *testing.T) {
|
|
httpClient, transport := newDownloadClientForTest(nil)
|
|
transport.statusCode = http.StatusInternalServerError
|
|
|
|
r := newRefresherForTest(t, t.TempDir(), httpClient)
|
|
|
|
err := r.refresh(t.Context())
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "received HTTP 500")
|
|
|
|
// No file is left behind, so the next attempt is still due right away
|
|
require.NoFileExists(t, r.service.dbPath)
|
|
require.Zero(t, r.timeUntilRefresh())
|
|
}
|
|
|
|
func TestRefresherSharedDirectorySkipsDownload(t *testing.T) {
|
|
// Replicas pointed at the same mount coordinate through the file itself: once one of them has refreshed it, the others find it fresh and don't download it again
|
|
database := readTestDatabase(t)
|
|
archive := buildTarGzForTest(t, map[string][]byte{"GeoLite2-City_20260101/" + databaseFileName: database})
|
|
httpClient, transport := newDownloadClientForTest(archive)
|
|
|
|
dir := t.TempDir()
|
|
first := newRefresherForTest(t, dir, httpClient)
|
|
second := newRefresherForTest(t, dir, httpClient)
|
|
|
|
require.Zero(t, first.timeUntilRefresh())
|
|
require.Zero(t, second.timeUntilRefresh())
|
|
|
|
err := first.refresh(t.Context())
|
|
require.NoError(t, err)
|
|
require.Equal(t, int32(1), transport.requests.Load())
|
|
|
|
// The second replica sees the file the first one wrote and goes back to sleep instead of downloading it again
|
|
require.Positive(t, second.timeUntilRefresh())
|
|
}
|
|
|
|
func TestRefresherWatchesForReplacedDatabase(t *testing.T) {
|
|
// Supplying a database by hand is how air-gapped deployments work, and it takes effect without a restart
|
|
r := newRefresherForTest(t, t.TempDir(), nil)
|
|
r.disabled = true
|
|
|
|
ctx := runRefresherForTest(t, r)
|
|
|
|
country, _, err := r.service.GetLocationByIP(ctx, "81.2.69.142")
|
|
require.NoError(t, err)
|
|
require.Empty(t, country)
|
|
|
|
writeDatabaseFileForTest(t, r.service.dbPath, readTestDatabase(t))
|
|
|
|
require.Eventually(t, func() bool {
|
|
country, _, err := r.service.GetLocationByIP(ctx, "81.2.69.142")
|
|
return err == nil && country == "United Kingdom"
|
|
}, 30*time.Second, 100*time.Millisecond, "the database put in place by hand was never picked up")
|
|
|
|
// Removing it stops lookups from resolving, rather than serving from a file that is gone
|
|
err = os.Remove(r.service.dbPath)
|
|
require.NoError(t, err)
|
|
|
|
require.Eventually(t, func() bool {
|
|
country, _, err := r.service.GetLocationByIP(ctx, "81.2.69.142")
|
|
return err == nil && country == ""
|
|
}, 30*time.Second, 100*time.Millisecond, "the removed database was still being served")
|
|
}
|
|
|
|
func TestRefresherRunRefreshesOnStart(t *testing.T) {
|
|
database := readTestDatabase(t)
|
|
archive := buildTarGzForTest(t, map[string][]byte{"GeoLite2-City_20260101/" + databaseFileName: database})
|
|
httpClient, transport := newDownloadClientForTest(archive)
|
|
|
|
r := newRefresherForTest(t, t.TempDir(), httpClient)
|
|
ctx := runRefresherForTest(t, r)
|
|
|
|
// A missing database is due right away, so the refresher downloads one as soon as it starts
|
|
require.Eventually(t, func() bool {
|
|
country, _, err := r.service.GetLocationByIP(ctx, "81.2.69.142")
|
|
return err == nil && country == "United Kingdom"
|
|
}, 30*time.Second, 100*time.Millisecond, "the database was never downloaded")
|
|
|
|
// It doesn't download again once the database on disk is fresh
|
|
require.Never(t, func() bool {
|
|
return transport.requests.Load() > 1
|
|
}, 3*time.Second, 250*time.Millisecond, "the database was downloaded again while it was still fresh")
|
|
}
|
|
|
|
func TestRefresherDisabledDoesNotDownload(t *testing.T) {
|
|
// Without a way to reach the download URL there's nothing to refresh, but the file is still watched
|
|
httpClient, transport := newDownloadClientForTest(nil)
|
|
|
|
r := newRefresherForTest(t, t.TempDir(), httpClient)
|
|
r.disabled = true
|
|
|
|
runRefresherForTest(t, r)
|
|
|
|
require.Never(t, func() bool {
|
|
return transport.requests.Load() > 0
|
|
}, 3*time.Second, 250*time.Millisecond, "the database was downloaded even though refreshes are disabled")
|
|
}
|