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

7.2 KB · 230 lines · Go Raw History
  1package web
  2
  3import (
  4	"net/http"
  5	"net/http/httptest"
  6	"testing"
  7	"testing/fstest"
  8)
  9
 10// A mistake in the client IP rule is invisible: it does not error, it
 11// attributes every visitor to the wrong address. Pinned rather than reasoned
 12// about.
 13func TestClientIP(t *testing.T) {
 14	tests := []struct {
 15		name    string
 16		headers map[string]string
 17		remote  string
 18		want    string
 19	}{
 20		{
 21			// Behind the tunnel, cloudflared sets XFF to the real client
 22			// and Caddy appends its own peer, so the last entry is a
 23			// bridge address on every request.
 24			name: "cloudflare wins over a trailing proxy hop",
 25			headers: map[string]string{
 26				"X-Forwarded-For":  "203.0.113.7, 172.18.0.4",
 27				"CF-Connecting-IP": "203.0.113.7",
 28			},
 29			remote: "172.18.0.5:41234",
 30			want:   "203.0.113.7",
 31		},
 32		{
 33			// Without Cloudflare the last entry is the hop that actually
 34			// connected; earlier entries are attacker controlled.
 35			name:    "falls back to the last forwarded entry",
 36			headers: map[string]string{"X-Forwarded-For": "203.0.113.7, 198.51.100.2"},
 37			remote:  "172.18.0.5:41234",
 38			want:    "198.51.100.2",
 39		},
 40		{
 41			name:    "bare connection uses the peer without its port",
 42			headers: nil,
 43			remote:  "198.51.100.9:51000",
 44			want:    "198.51.100.9",
 45		},
 46		{
 47			// A spoofed XFF does beat the real peer on a direct request.
 48			// Accepted, because Caddy is always in front in production.
 49			name:    "single forwarded entry is taken at face value",
 50			headers: map[string]string{"X-Forwarded-For": "203.0.113.7"},
 51			remote:  "172.18.0.5:41234",
 52			want:    "203.0.113.7",
 53		},
 54	}
 55
 56	for _, tt := range tests {
 57		t.Run(tt.name, func(t *testing.T) {
 58			r := httptest.NewRequest(http.MethodGet, "/", nil)
 59			r.RemoteAddr = tt.remote
 60			for k, v := range tt.headers {
 61				r.Header.Set(k, v)
 62			}
 63			if got := ClientIP(r); got != tt.want {
 64				t.Errorf("ClientIP() = %q, want %q", got, tt.want)
 65			}
 66		})
 67	}
 68}
 69
 70// Caching the wrong file for a year cannot be undone from the server side:
 71// every browser that saw it holds it until the year is up.
 72func TestStaticCachePolicy(t *testing.T) {
 73	dist := fstest.MapFS{
 74		".vite/manifest.json": &fstest.MapFile{Data: []byte(`{
 75			"index.js": {"file": "base-AAAA.js", "src": "index.js", "isEntry": true,
 76			             "css": ["base-BBBB.css"]}
 77		}`)},
 78		"base-AAAA.js":  &fstest.MapFile{Data: []byte("//js")},
 79		"base-BBBB.css": &fstest.MapFile{Data: []byte("/*css*/")},
 80		"pdfs/cv.pdf":   &fstest.MapFile{Data: []byte("%PDF")},
 81		"images/a.webp": &fstest.MapFile{Data: []byte("webp")},
 82	}
 83
 84	assets, err := LoadAssets(dist)
 85	if err != nil {
 86		t.Fatalf("LoadAssets: %v", err)
 87	}
 88	handler := Static(dist, assets)
 89
 90	const immutable = "public, max-age=31536000, immutable"
 91	const short = "public, max-age=3600"
 92
 93	tests := []struct {
 94		path string
 95		want string
 96		code int
 97	}{
 98		{"/static/base-AAAA.js", immutable, http.StatusOK},
 99		{"/static/base-BBBB.css", immutable, http.StatusOK},
100		// Copied through from publicDir unhashed, so a year-long cache
101		// would mean an updated resume never reaching anybody.
102		{"/static/pdfs/cv.pdf", short, http.StatusOK},
103		{"/static/images/a.webp", short, http.StatusOK},
104	}
105
106	for _, tt := range tests {
107		t.Run(tt.path, func(t *testing.T) {
108			w := httptest.NewRecorder()
109			handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tt.path, nil))
110			if w.Code != tt.code {
111				t.Fatalf("status = %d, want %d", w.Code, tt.code)
112			}
113			if got := w.Header().Get("Cache-Control"); got != tt.want {
114				t.Errorf("Cache-Control = %q, want %q", got, tt.want)
115			}
116		})
117	}
118
119	// Build metadata. The server reads it; nothing else has a use for it.
120	t.Run("manifest is not served", func(t *testing.T) {
121		w := httptest.NewRecorder()
122		handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/.vite/manifest.json", nil))
123		if w.Code != http.StatusNotFound {
124			t.Errorf("status = %d, want 404", w.Code)
125		}
126	})
127}
128
129// EdgeCache fills in the site policy for a handler that sets no Cache-Control,
130// which is what makes a silent handler cacheable. A handler that has an opinion
131// keeps it, and every /healthz in this repo relies on that to say no-store:
132// without it the edge answers a liveness check out of cache long after the
133// origin has stopped serving, which is exactly what blog.bythewood.me did.
134func TestEdgeCacheYieldsToTheHandler(t *testing.T) {
135	const policy = "public, max-age=14400"
136
137	tests := []struct {
138		name    string
139		handler http.HandlerFunc
140		want    string
141	}{
142		{
143			name: "silent handler takes the site policy",
144			handler: func(w http.ResponseWriter, r *http.Request) {
145				_, _ = w.Write([]byte("ok"))
146			},
147			want: policy,
148		},
149		{
150			name: "no-store survives",
151			handler: func(w http.ResponseWriter, r *http.Request) {
152				w.Header().Set("Cache-Control", "no-store")
153				_, _ = w.Write([]byte("ok"))
154			},
155			want: "no-store",
156		},
157		{
158			name: "an explicit policy survives",
159			handler: func(w http.ResponseWriter, r *http.Request) {
160				w.Header().Set("Cache-Control", "private, max-age=30")
161				w.WriteHeader(http.StatusOK)
162			},
163			want: "private, max-age=30",
164		},
165		{
166			// Cloudflare stamps its own TTL on a header-less response, so an
167			// error has to say no-store or a 404 is held at the edge.
168			name: "an error is never cacheable",
169			handler: func(w http.ResponseWriter, r *http.Request) {
170				w.WriteHeader(http.StatusNotFound)
171			},
172			want: "no-store",
173		},
174	}
175
176	for _, tt := range tests {
177		t.Run(tt.name, func(t *testing.T) {
178			w := httptest.NewRecorder()
179			EdgeCache(policy)(tt.handler).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
180
181			if got := w.Header().Get("Cache-Control"); got != tt.want {
182				t.Errorf("Cache-Control = %q, want %q", got, tt.want)
183			}
184		})
185	}
186}
187
188// component is a rollup dimension in the log store, so this set has to stay
189// small and every request has to land in it. An empty component was why the
190// dashboard could group requests by status but never by what kind of route
191// served them.
192func TestRouteClassIsBoundedAndTotal(t *testing.T) {
193	seen := map[string]bool{}
194
195	for _, tc := range []struct {
196		path        string
197		contentType string
198		want        string
199	}{
200		{"/", "text/html", "page"},
201		{"/posts/some-slug/", "text/html", "page"},
202		{"/orchard/blob/main/go.mod", "text/html", "page"},
203		{"/login", "text/html", "page"},
204		{"/static/base-abc123.js", "text/javascript", "static"},
205		{"/content/images/avatar.webp", "image/webp", "static"},
206		{"/og/post.png", "image/png", "static"},
207		{"/media/images/old.webp", "text/html", "static"},
208		{"/_next/image", "text/html", "static"},
209		{"/robots.txt", "text/plain", "asset"},
210		{"/sitemap.xml", "application/xml", "asset"},
211		{"/favicon.ico", "image/x-icon", "asset"},
212		{"/healthz", "text/plain", "healthz"},
213		{"/events", "text/event-stream", "stream"},
214		// Content type wins: a stream is a stream whatever it is served from.
215		{"/api/live", "text/event-stream; charset=utf-8", "stream"},
216	} {
217		rec := &recorder{ResponseWriter: httptest.NewRecorder()}
218		rec.Header().Set("Content-Type", tc.contentType)
219		got := routeClass(rec, tc.path)
220		if got != tc.want {
221			t.Errorf("routeClass(%q, %q) = %q, want %q", tc.path, tc.contentType, got, tc.want)
222		}
223		seen[got] = true
224	}
225
226	if len(seen) > 5 {
227		t.Errorf("routeClass produced %d values, and it is a rollup key: %v", len(seen), seen)
228	}
229}