orchard
mirrorEvery 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
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}