carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "net"
8 "net/http"
9 "os"
10 "path/filepath"
11 "testing"
12 "time"
13)
14
15func dialPurgeSocket(t *testing.T, socketPath string) *http.Client {
16 t.Helper()
17 return &http.Client{
18 Transport: &http.Transport{
19 DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
20 return net.Dial("unix", socketPath)
21 },
22 },
23 }
24}
25
26func TestListenPurgeRemovesCachedEntry(t *testing.T) {
27 dir := t.TempDir()
28 sock := filepath.Join(dir, "instance.sock")
29 cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
30 cache.Put(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}, "", &CachedResponse{
31 StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
32 })
33
34 pl, err := ListenPurge(sock, "a.example", cache)
35 if err != nil {
36 t.Fatalf("ListenPurge: %v", err)
37 }
38 defer pl.Close()
39
40 body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""})
41 client := dialPurgeSocket(t, sock)
42 resp, err := client.Post("http://unix/purge", "application/json", bytes.NewReader(body))
43 if err != nil {
44 t.Fatalf("purge POST: %v", err)
45 }
46 resp.Body.Close()
47 if resp.StatusCode != http.StatusOK {
48 t.Fatalf("purge status = %d, want 200", resp.StatusCode)
49 }
50
51 if _, ok := cache.get(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}); ok {
52 t.Fatal("expected entry to be purged")
53 }
54}
55
56func TestListenPurgeCannotAffectAnotherHost(t *testing.T) {
57 dir := t.TempDir()
58 sock := filepath.Join(dir, "instance.sock")
59 cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
60 cache.Put(CacheKey{Host: "other.example", Method: "GET", Path: "/event"}, "", &CachedResponse{
61 StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
62 })
63
64 pl, err := ListenPurge(sock, "a.example", cache) // bound to a.example, not other.example
65 if err != nil {
66 t.Fatalf("ListenPurge: %v", err)
67 }
68 defer pl.Close()
69
70 body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""})
71 client := dialPurgeSocket(t, sock)
72 resp, _ := client.Post("http://unix/purge", "application/json", bytes.NewReader(body))
73 resp.Body.Close()
74
75 if _, ok := cache.get(CacheKey{Host: "other.example", Method: "GET", Path: "/event"}); !ok {
76 t.Fatal("a purge listener bound to a.example must never affect other.example's cache entries")
77 }
78}
79
80func TestListenPurgeSocketFileIsOwnerOnly(t *testing.T) {
81 dir := t.TempDir()
82 sock := filepath.Join(dir, "instance.sock")
83 pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20))
84 if err != nil {
85 t.Fatalf("ListenPurge: %v", err)
86 }
87 defer pl.Close()
88
89 fi, err := os.Stat(sock)
90 if err != nil {
91 t.Fatalf("stat socket: %v", err)
92 }
93 if mode := fi.Mode().Perm(); mode != 0600 {
94 t.Fatalf("socket mode = %o, want 0600", mode)
95 }
96}
97
98// Close, called immediately after ListenPurge returns (before the Serve
99// goroutine it started has necessarily run at all), must still unlink the
100// socket file. PurgeListener.Close used to delegate to http.Server.Close,
101// which only closes listeners its own Serve call has already registered in
102// its internal tracking map (net/http's trackListener, set inside Serve
103// itself) — a real race, since Serve runs in a goroutine ListenPurge doesn't
104// wait on. Losing that race silently no-ops Close: the goroutine's later
105// Serve(ln) call then Accepts on a listener nobody ever asked to stop, and
106// the socket file is never removed. A caller like UnitBackend.Start/Stop
107// that opens and closes a purge listener back-to-back around a single unit
108// start hits this every time, not just occasionally.
109func TestListenPurgeCloseImmediatelyAfterOpenRemovesSocketFile(t *testing.T) {
110 dir := shortPurgeSocketDir(t)
111 sock := filepath.Join(dir, "instance.sock")
112 pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20))
113 if err != nil {
114 t.Fatalf("ListenPurge: %v", err)
115 }
116
117 if err := pl.Close(); err != nil {
118 t.Fatalf("Close: %v", err)
119 }
120
121 if _, err := os.Stat(sock); err == nil {
122 t.Fatal("expected the socket file removed immediately after Close, even called before the Serve goroutine could have run")
123 }
124}
125
126// Close must tear down connections already accepted before it runs, not
127// just stop new ones. Closing the raw listener alone (the fix for the
128// unlink race above) only stops future Accept calls — a connection the
129// server already handed off to its own handler goroutine keeps right on
130// serving requests, since nothing told that goroutine to stop. A client
131// whose transport pooled a keep-alive connection from a purge issued before
132// Close can therefore keep issuing purges after Close returns, defeating
133// the whole point of closing the listener.
134func TestListenPurgeCloseRefusesRequestsOnAlreadyEstablishedConnections(t *testing.T) {
135 dir := shortPurgeSocketDir(t)
136 sock := filepath.Join(dir, "instance.sock")
137 cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
138 cache.Put(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}, "", &CachedResponse{
139 StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
140 })
141
142 pl, err := ListenPurge(sock, "a.example", cache)
143 if err != nil {
144 t.Fatalf("ListenPurge: %v", err)
145 }
146
147 client := dialPurgeSocket(t, sock)
148 body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""})
149
150 // Establish (and, via keep-alive, pool) a connection with one successful
151 // purge before Close.
152 resp, err := client.Post("http://unix/purge", "application/json", bytes.NewReader(body))
153 if err != nil {
154 t.Fatalf("first purge POST: %v", err)
155 }
156 resp.Body.Close()
157 if resp.StatusCode != http.StatusOK {
158 t.Fatalf("first purge status = %d, want 200", resp.StatusCode)
159 }
160
161 if err := pl.Close(); err != nil {
162 t.Fatalf("Close: %v", err)
163 }
164
165 // The SAME client — free to reuse its pooled connection from the first
166 // request — must not be able to complete a second purge after Close.
167 resp2, err := client.Post("http://unix/purge", "application/json", bytes.NewReader(body))
168 if err == nil {
169 resp2.Body.Close()
170 t.Fatal("expected a purge over a connection established before Close to fail once Close has run, got success")
171 }
172}
173
174func TestListenPurgeRemovesStaleSocketFile(t *testing.T) {
175 dir := t.TempDir()
176 sock := filepath.Join(dir, "instance.sock")
177 if err := os.WriteFile(sock, []byte("stale"), 0600); err != nil {
178 t.Fatal(err)
179 }
180 pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20))
181 if err != nil {
182 t.Fatalf("ListenPurge should clean up a stale socket file, got: %v", err)
183 }
184 pl.Close()
185}
186
187// TestListenPurgeStarPurgesTheWholeHost — {"path":"*"} is the socket twin of
188// a Carlos-Purge: * header (#173): every entry of the listener's own host, and
189// still never another host's.
190func TestListenPurgeStarPurgesTheWholeHost(t *testing.T) {
191 sock := filepath.Join(t.TempDir(), "instance.sock")
192 cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
193 put := func(host, path, query string) CacheKey {
194 k := CacheKey{Host: host, Method: "GET", Path: path, RawQuery: query}
195 cache.Put(k, "", &CachedResponse{
196 StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
197 })
198 return k
199 }
200 mine := []CacheKey{put("a.example", "/one", ""), put("a.example", "/two", "q=1")}
201 other := put("other.example", "/one", "")
202
203 pl, err := ListenPurge(sock, "a.example", cache)
204 if err != nil {
205 t.Fatalf("ListenPurge: %v", err)
206 }
207 defer pl.Close()
208
209 body, _ := json.Marshal(map[string]string{"path": "*"})
210 resp, err := dialPurgeSocket(t, sock).Post("http://unix/purge", "application/json", bytes.NewReader(body))
211 if err != nil {
212 t.Fatalf("purge POST: %v", err)
213 }
214 resp.Body.Close()
215 if resp.StatusCode != http.StatusOK {
216 t.Fatalf("purge status = %d, want 200", resp.StatusCode)
217 }
218 for _, k := range mine {
219 if _, ok := cache.get(k); ok {
220 t.Fatalf("%s%s survived a whole-host purge", k.Path, k.RawQuery)
221 }
222 }
223 if _, ok := cache.get(other); !ok {
224 t.Fatal("a whole-host purge on a.example's socket reached other.example")
225 }
226}
227
228// TestListenPurgeStarWithAQueryIsRefused — "*" with a query names nothing a
229// request could be stored under, so it is a malformed body, not a whole-host
230// purge and not an exact purge of a path literally called "*".
231func TestListenPurgeStarWithAQueryIsRefused(t *testing.T) {
232 sock := filepath.Join(t.TempDir(), "instance.sock")
233 cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
234 k := CacheKey{Host: "a.example", Method: "GET", Path: "/one"}
235 cache.Put(k, "", &CachedResponse{
236 StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
237 })
238
239 pl, err := ListenPurge(sock, "a.example", cache)
240 if err != nil {
241 t.Fatalf("ListenPurge: %v", err)
242 }
243 defer pl.Close()
244
245 body, _ := json.Marshal(map[string]string{"path": "*", "query": "x=1"})
246 resp, err := dialPurgeSocket(t, sock).Post("http://unix/purge", "application/json", bytes.NewReader(body))
247 if err != nil {
248 t.Fatalf("purge POST: %v", err)
249 }
250 resp.Body.Close()
251 if resp.StatusCode != http.StatusBadRequest {
252 t.Fatalf("purge status = %d, want 400", resp.StatusCode)
253 }
254 if _, ok := cache.get(k); !ok {
255 t.Fatal("a refused body purged the host anyway")
256 }
257}
258
259func shortPurgeSocketDir(t *testing.T) string {
260 t.Helper()
261 dir, err := os.MkdirTemp(os.TempDir(), "purge-")
262 if err != nil {
263 t.Fatal(err)
264 }
265 t.Cleanup(func() {
266 if err := os.RemoveAll(dir); err != nil {
267 t.Error(err)
268 }
269 })
270 return dir
271}
272