repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

4.0 KB · 129 lines · Go Raw History
  1package tools
  2
  3import (
  4	"testing"
  5	"time"
  6)
  7
  8type fakeStore struct {
  9	saved   map[string]int
 10	cleared []string
 11}
 12
 13func (f *fakeStore) SavePenalty(host string, till time.Time, trips int) {
 14	if f.saved == nil {
 15		f.saved = map[string]int{}
 16	}
 17	f.saved[host] = trips
 18}
 19func (f *fakeStore) ClearPenalty(host string) { f.cleared = append(f.cleared, host) }
 20
 21// A host that refuses is left alone for hours and not for ten minutes, because
 22// a short box means asking again six times an hour and every one of those is a
 23// request to something that already said no.
 24func TestGuardLeavesARefusingHostAlone(t *testing.T) {
 25	g := NewGuard(10 * time.Minute)
 26	g.Trip("example.com")
 27	blocked, left := g.Blocked("example.com")
 28	if !blocked {
 29		t.Fatal("a host that refused is not boxed")
 30	}
 31	if left < refusalCool-time.Minute {
 32		t.Errorf("boxed for only %s, want about %s", left, refusalCool)
 33	}
 34	// Flat rather than escalating, so nothing has to be poked to find the step.
 35	g.Trip("example.com")
 36	if _, again := g.Blocked("example.com"); again > refusalCool+time.Minute {
 37		t.Errorf("a second refusal stretched the box to %s", again)
 38	}
 39}
 40
 41// One bad afternoon must not leave a host on a four hour backoff for the rest
 42// of the process, so a call that worked resets the streak.
 43func TestGuardOKResetsTheStreak(t *testing.T) {
 44	store := &fakeStore{}
 45	g := NewGuard(10 * time.Minute)
 46	g.Restore(store, nil)
 47
 48	g.Trip("example.com")
 49	g.Trip("example.com")
 50	g.OK("example.com")
 51	if len(store.cleared) != 1 || store.cleared[0] != "example.com" {
 52		t.Errorf("a host that answered was not cleared: %v", store.cleared)
 53	}
 54
 55}
 56
 57// A deploy used to empty the penalty box, so the next turn asked a host that
 58// was still refusing. That is the surest way to keep a rate limit alive.
 59func TestGuardSurvivesARestart(t *testing.T) {
 60	store := &fakeStore{}
 61	g := NewGuard(10 * time.Minute)
 62	g.Restore(store, nil)
 63	g.Trip(SearchHost)
 64	if store.saved[SearchHost] != 1 {
 65		t.Fatalf("the trip was not persisted: %v", store.saved)
 66	}
 67
 68	// A new process, handed what the last one wrote.
 69	fresh := NewGuard(10 * time.Minute)
 70	fresh.Restore(store, map[string][2]int64{
 71		SearchHost: {time.Now().Add(30 * time.Minute).UnixMilli(), 3},
 72	})
 73	blocked, left := fresh.Blocked(SearchHost)
 74	if !blocked {
 75		t.Fatal("a restart cleared the penalty box")
 76	}
 77	if left < 25*time.Minute {
 78		t.Errorf("restored box has %s left, want about 30 minutes", left)
 79	}
 80}
 81
 82// Down is what the page reads to say search is unavailable.
 83func TestGuardDownListsOnlyLiveBoxes(t *testing.T) {
 84	g := NewGuard(10 * time.Minute)
 85	g.Trip(SearchHost)
 86	down := g.Down()
 87	if _, ok := down[SearchHost]; !ok {
 88		t.Errorf("a tripped host is not reported down: %v", down)
 89	}
 90	if _, ok := down["never-called.example"]; ok {
 91		t.Error("a host that was never tripped is reported down")
 92	}
 93}
 94
 95// A slow page is not a ban. Before this split, one timeout put a host in the
 96// same six hour box DuckDuckGo gets, which on 2026-09-08 took
 97// developer.android.com, mirrors.wikimedia.org and hacker-news.firebaseio.com
 98// out of reach for the afternoon.
 99func TestAStumbleIsBoxedBrieflyAndARefusalIsNot(t *testing.T) {
100	g := NewGuard(time.Minute)
101	g.Stumble("developer.android.com")
102	blocked, left := g.Blocked("developer.android.com")
103	if !blocked {
104		t.Fatal("a host that timed out was not held off at all")
105	}
106	if left > stumbleCool {
107		t.Errorf("a timeout was boxed for %s, want no more than %s", left, stumbleCool)
108	}
109
110	g.Trip("html.duckduckgo.com")
111	_, banned := g.Blocked("html.duckduckgo.com")
112	if banned <= stumbleCool {
113		t.Errorf("an outright refusal was boxed for %s, want the full %s", banned, refusalCool)
114	}
115}
116
117// A page timing out on a host that already refused us must not shorten the box
118// it is in, which is what would happen if the later, smaller deadline won.
119func TestAStumbleDoesNotCutShortARefusal(t *testing.T) {
120	g := NewGuard(time.Minute)
121	g.Trip("html.duckduckgo.com")
122	_, before := g.Blocked("html.duckduckgo.com")
123	g.Stumble("html.duckduckgo.com")
124	_, after := g.Blocked("html.duckduckgo.com")
125	if after < before-time.Minute {
126		t.Errorf("a stumble cut the refusal from %s to %s", before, after)
127	}
128}