carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "net/http/httputil"
11 "net/url"
12 "strings"
13 "sync/atomic"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/geoip"
18 "github.com/carlosframework/platform/internal/status"
19)
20
21func putGeoip(t *testing.T, base, token string, body io.Reader, size int64) (int, string) {
22 t.Helper()
23 req, _ := http.NewRequest(http.MethodPut, base+"/api/cli/system/geoip", body)
24 req.ContentLength = size
25 req.Header.Set("Content-Type", "application/octet-stream")
26 req.Header.Set("Authorization", "Bearer "+token)
27 res, err := http.DefaultClient.Do(req)
28 if err != nil {
29 t.Fatal(err)
30 }
31 defer res.Body.Close()
32 b, _ := io.ReadAll(res.Body)
33 return res.StatusCode, string(b)
34}
35
36func jobID(t *testing.T, body string) string {
37 t.Helper()
38 i := strings.Index(body, `"job":"`)
39 if i < 0 {
40 t.Fatalf("no job id in %q", body)
41 }
42 rest := body[i+len(`"job":"`):]
43 return rest[:strings.IndexByte(rest, '"')]
44}
45
46func TestGeoipPublishIsAJobAndReturnsItsPointer(t *testing.T) {
47 withFixtureProbes(t)
48 fx := newConsoleFixture(t)
49 owner := cliToken(t, fx, "paul@keymail.dev", "code-geoip-put")
50 body := fixture(t, "GeoIP2-City-Test.mmdb")
51 st, out := putGeoip(t, fx.ts.URL, owner, bytes.NewReader(body), int64(len(body)))
52 if st != http.StatusAccepted {
53 t.Fatalf("PUT = %d %s, want 202", st, out)
54 }
55 job := waitJob(t, fx, owner, jobID(t, out))
56 if job["error"] != "" {
57 t.Fatalf("job error = %v", job["error"])
58 }
59 res, _ := job["result"].(map[string]any)
60 if res == nil || res["database_type"] != "GeoIP2-City" {
61 t.Fatalf("job result = %v, want the published pointer", job["result"])
62 }
63 if _, _, err := fx.rel.Get(context.Background(), geoip.PointerKey); err != nil {
64 t.Fatalf("pointer not written: %v", err)
65 }
66}
67
68func TestGeoipPublishRefusalIsTheJobsError(t *testing.T) {
69 withFixtureProbes(t)
70 fx := newConsoleFixture(t)
71 owner := cliToken(t, fx, "paul@keymail.dev", "code-geoip-junk")
72 st, out := putGeoip(t, fx.ts.URL, owner, strings.NewReader("junk"), 4)
73 if st != http.StatusAccepted {
74 t.Fatalf("PUT junk = %d %s, want 202 (verification is the job's)", st, out)
75 }
76 job := waitJob(t, fx, owner, jobID(t, out))
77 if e, _ := job["error"].(string); !strings.Contains(e, "not a valid MaxMind-format database") {
78 t.Fatalf("job error = %q", e)
79 }
80}
81
82func TestGeoipDoorsAreForOperatorsOnly(t *testing.T) {
83 fx := newConsoleFixture(t)
84 if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil {
85 t.Fatal(err)
86 }
87 member := cliToken(t, fx, "member@keymail.dev", "code-geoip-member")
88 _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member)
89 if st, body := getRaw(t, fx.ts.URL+"/api/cli/system/geoip", member); st != http.StatusNotFound || body != want {
90 t.Errorf("GET as a member = %d %q, want the bare 404", st, body)
91 }
92 if st, _ := putGeoip(t, fx.ts.URL, member, strings.NewReader("x"), 1); st != http.StatusNotFound {
93 t.Errorf("PUT as a member = %d, want 404", st)
94 }
95}
96
97// Codex R5-1: the slot is reserved before any of the body is read.
98func TestSecondUploadIsRefusedBeforeItsBodyIsRead(t *testing.T) {
99 fx := newConsoleFixture(t)
100 owner := cliToken(t, fx, "paul@keymail.dev", "code-geoip-slot")
101 pr, pw := io.Pipe()
102 defer pw.Close()
103 go func() { pw.Write(make([]byte, 1024)) }() // then stall
104 first := make(chan struct{})
105 go func() {
106 // Not putGeoip: t.Fatal from a goroutine would exit it without
107 // closing first. The first upload ends in an error by design.
108 defer close(first)
109 req, _ := http.NewRequest(http.MethodPut, fx.ts.URL+"/api/cli/system/geoip", pr)
110 req.ContentLength = 1 << 20
111 req.Header.Set("Authorization", "Bearer "+owner)
112 if res, err := http.DefaultClient.Do(req); err == nil {
113 res.Body.Close()
114 }
115 }()
116 deadline := time.Now().Add(5 * time.Second)
117 for {
118 if !fx.srv.geoip.mu.TryLock() {
119 break // the first upload holds the slot
120 }
121 fx.srv.geoip.mu.Unlock()
122 if time.Now().After(deadline) {
123 t.Fatal("the first upload never took the slot")
124 }
125 time.Sleep(5 * time.Millisecond)
126 }
127 var read atomic.Bool
128 second := readSpy{r: strings.NewReader("second"), read: &read}
129 if st, out := putGeoip(t, fx.ts.URL, owner, second, 6); st != http.StatusConflict {
130 t.Fatalf("second PUT = %d %s, want 409", st, out)
131 }
132 // The client may have sent the bytes; what matters is the handler never
133 // read them, which an unread body plus a 409 shows from the server's side:
134 // the slot is still the first upload's.
135 if fx.srv.geoip.mu.TryLock() {
136 fx.srv.geoip.mu.Unlock()
137 t.Fatal("the refusal released the first upload's slot")
138 }
139 pw.CloseWithError(io.ErrUnexpectedEOF)
140 <-first
141 _ = read.Load()
142}
143
144type readSpy struct {
145 r io.Reader
146 read *atomic.Bool
147}
148
149func (s readSpy) Read(p []byte) (int, error) { s.read.Store(true); return s.r.Read(p) }
150
151// Codex R4-2: the 202 arrives through a proxy with a short response-header
152// timeout even when verification takes longer.
153func TestGeoipPublishAnswersBeforeVerificationThroughABackhaul(t *testing.T) {
154 withFixtureProbes(t)
155 fx := newConsoleFixture(t)
156 owner := cliToken(t, fx, "paul@keymail.dev", "code-geoip-backhaul")
157 geoipCommitDelay = 300 * time.Millisecond
158 t.Cleanup(func() { geoipCommitDelay = 0 })
159 target, _ := url.Parse(fx.ts.URL)
160 rp := httputil.NewSingleHostReverseProxy(target)
161 rp.Transport = &http.Transport{ResponseHeaderTimeout: 100 * time.Millisecond}
162 proxy := httptest.NewServer(rp)
163 t.Cleanup(proxy.Close)
164 body := fixture(t, "GeoIP2-City-Test.mmdb")
165 st, out := putGeoip(t, proxy.URL, owner, bytes.NewReader(body), int64(len(body)))
166 if st != http.StatusAccepted {
167 t.Fatalf("PUT through the proxy = %d %s, want 202 before verification", st, out)
168 }
169 if job := waitJob(t, fx, owner, jobID(t, out)); job["error"] != "" {
170 t.Fatalf("job = %v", job)
171 }
172}
173
174func seedGeoBox(t *testing.T, fx *consoleFixture, label string, g *status.GeoIPState, at time.Time) {
175 t.Helper()
176 b := status.CollectBox(nil, status.Probes{Release: "v1", GeoIP: g}, at)
177 b.Label = label
178 if err := status.WriteBox(context.Background(), fx.rel, b); err != nil {
179 t.Fatal(err)
180 }
181}
182
183func seedEdgesRecord(t *testing.T, fx *consoleFixture, label string) {
184 t.Helper()
185 if _, err := fx.rel.Put(context.Background(), "control/edges/"+label+".json", []byte(`{"v":1,"box":"`+label+`","eip":"192.0.2.1","hosts":{}}`), ""); err != nil {
186 t.Fatal(err)
187 }
188}
189
190func TestGeoipStatusVerdict(t *testing.T) {
191 withFixtureProbes(t)
192 fx := newConsoleFixture(t)
193 owner := cliToken(t, fx, "paul@keymail.dev", "code-geoip-status")
194 ptr, err := fx.srv.geoip.Publish(context.Background(), bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"})
195 if err != nil {
196 t.Fatal(err)
197 }
198 now := time.Now()
199 seedEdgesRecord(t, fx, "ie-1")
200 seedEdgesRecord(t, fx, "uk-1")
201 seedGeoBox(t, fx, "ie-1", &status.GeoIPState{Mode: "mirror", SHA256: ptr.SHA256}, now)
202 // Codex R3-4: a heartbeat just inside the next jittered tick is fresh.
203 seedGeoBox(t, fx, "uk-1", &status.GeoIPState{Mode: "mirror", SHA256: ptr.SHA256}, now.Add(-(status.SnapshotPeriod + 59*time.Second)))
204 st, out := getJSON(t, fx.ts.URL+"/api/cli/system/geoip", owner)
205 if st != http.StatusOK || out["ready"] != true {
206 t.Fatalf("status = %d %v, want ready", st, out)
207 }
208 // An old binary, an expected box with no heartbeat, and a stale one.
209 seedEdgesRecord(t, fx, "us-1")
210 seedGeoBox(t, fx, "us-1", nil, now)
211 seedEdgesRecord(t, fx, "au-1")
212 seedEdgesRecord(t, fx, "sg-1")
213 seedGeoBox(t, fx, "sg-1", &status.GeoIPState{Mode: "mirror", SHA256: ptr.SHA256}, now.Add(-2*time.Hour))
214 _, out = getJSON(t, fx.ts.URL+"/api/cli/system/geoip", owner)
215 if out["ready"] != false {
216 t.Fatalf("status = %v, want not ready", out)
217 }
218 blocking, _ := out["blocking"].([]any)
219 joined := ""
220 for _, b := range blocking {
221 joined += b.(string) + "\n"
222 }
223 for _, want := range []string{"us-1: runs a binary", "au-1: no heartbeat", "sg-1: no heartbeat since", "control/edges/sg-1.json"} {
224 if !strings.Contains(joined, want) {
225 t.Errorf("blocking lacks %q:\n%s", want, joined)
226 }
227 }
228}
229
230func TestGeoipVerdictIncompleteReadIsNeverReady(t *testing.T) {
231 ready, _, blocking := geoipVerdict(geoipWant{state: geoipOff}, geoipInventoryRead{expected: map[geoipBoxKey]bool{}, boxes: map[geoipBoxKey]status.Box{}, expectedComplete: false, heartbeatsComplete: true}, time.Now())
232 if ready || len(blocking) == 0 {
233 t.Fatalf("incomplete read = ready %v, blocking %v", ready, blocking)
234 }
235}
236
237func TestGeoipOff(t *testing.T) {
238 withFixtureProbes(t)
239 fx := newConsoleFixture(t)
240 owner := cliToken(t, fx, "paul@keymail.dev", "code-geoip-off")
241 if _, err := fx.srv.geoip.Publish(context.Background(), bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"}); err != nil {
242 t.Fatal(err)
243 }
244 req, _ := http.NewRequest(http.MethodDelete, fx.ts.URL+"/api/cli/system/geoip", nil)
245 req.Header.Set("Authorization", "Bearer "+owner)
246 res, err := http.DefaultClient.Do(req)
247 if err != nil {
248 t.Fatal(err)
249 }
250 res.Body.Close()
251 if res.StatusCode != http.StatusOK {
252 t.Fatalf("DELETE = %d", res.StatusCode)
253 }
254 if _, _, err := fx.rel.Get(context.Background(), geoip.PointerKey); err == nil {
255 t.Fatal("pointer survived off")
256 }
257}
258
259// Codex R8-1: identity is (namespace, label). A fresh platform box must not
260// stand in for a fleet box of the same label, nor one fleet's for another's.
261func TestGeoipVerdictKeysBoxesByNamespace(t *testing.T) {
262 now := time.Now()
263 fresh := func(label string) status.Box {
264 b := status.CollectBox(nil, status.Probes{Release: "v1", GeoIP: &status.GeoIPState{Mode: "mirror", SHA256: "s"}}, now)
265 b.Label = label
266 return b
267 }
268 old := status.CollectBox(nil, status.Probes{Release: "v0"}, now)
269 old.Label = "node-1"
270 inv := geoipInventoryRead{
271 expected: map[geoipBoxKey]bool{{"", "node-1"}: true, {"tito", "node-1"}: true, {"bae", "node-1"}: true},
272 boxes: map[geoipBoxKey]status.Box{{"", "node-1"}: fresh("node-1"), {"tito", "node-1"}: fresh("node-1"), {"bae", "node-1"}: old},
273 expectedComplete: true, heartbeatsComplete: true,
274 }
275 ready, _, blocking := geoipVerdict(geoipWant{state: geoipLive, sha: "s"}, inv, now)
276 if ready || len(blocking) != 1 || !strings.HasPrefix(blocking[0], "bae/node-1: runs a binary") {
277 t.Fatalf("ready=%v blocking=%v, want only bae/node-1 blocking", ready, blocking)
278 }
279 delete(inv.boxes, geoipBoxKey{"tito", "node-1"})
280 _, _, blocking = geoipVerdict(geoipWant{state: geoipLive, sha: "s"}, inv, now)
281 found := false
282 for _, b := range blocking {
283 found = found || strings.HasPrefix(b, "tito/node-1: no heartbeat")
284 }
285 if !found {
286 t.Fatalf("a missing fleet box was satisfied by a same-label box elsewhere: %v", blocking)
287 }
288}
289
290// Codex R8 on R1-16: a box with no edges record (retired) is listed but
291// never blocks.
292func TestGeoipVerdictRetiredBoxDoesNotBlock(t *testing.T) {
293 now := time.Now()
294 b := status.CollectBox(nil, status.Probes{Release: "v0"}, now.Add(-time.Hour))
295 b.Label = "gone-1"
296 inv := geoipInventoryRead{expected: map[geoipBoxKey]bool{}, boxes: map[geoipBoxKey]status.Box{{"", "gone-1"}: b}, expectedComplete: true, heartbeatsComplete: false}
297 ready, rows, _ := geoipVerdict(geoipWant{state: geoipOff}, inv, now)
298 if !ready || len(rows) != 1 {
299 t.Fatalf("ready=%v rows=%v, want ready with the retired box listed", ready, rows)
300 }
301}
302
303// Codex R9-1: an unreadable pointer is never ready, and off requires every
304// mirror to have dropped its generation.
305func TestGeoipVerdictPointerStates(t *testing.T) {
306 now := time.Now()
307 mirror := func(sha string) status.Box {
308 b := status.CollectBox(nil, status.Probes{Release: "v1", GeoIP: &status.GeoIPState{Mode: "mirror", SHA256: sha}}, now)
309 b.Label = "ie-1"
310 return b
311 }
312 inv := func(sha string) geoipInventoryRead {
313 return geoipInventoryRead{expected: map[geoipBoxKey]bool{{"", "ie-1"}: true},
314 boxes: map[geoipBoxKey]status.Box{{"", "ie-1"}: mirror(sha)}, expectedComplete: true, heartbeatsComplete: true}
315 }
316 if ready, _, _ := geoipVerdict(geoipWant{state: geoipUnknown}, inv("a"), now); ready {
317 t.Fatal("an unreadable pointer reported ready")
318 }
319 if ready, _, b := geoipVerdict(geoipWant{state: geoipOff}, inv("a"), now); ready || len(b) != 1 || !strings.Contains(b[0], "still has") {
320 t.Fatalf("off with a mirror still loaded = %v %v", ready, b)
321 }
322 if ready, _, _ := geoipVerdict(geoipWant{state: geoipOff}, inv(""), now); !ready {
323 t.Fatal("off with every mirror empty must be ready")
324 }
325 if ready, _, _ := geoipVerdict(geoipWant{state: geoipLive, sha: "b"}, inv("a"), now); ready {
326 t.Fatal("a mirror on another sha reported ready")
327 }
328}
329
330// Codex R9-2: off never waits behind a publish.
331func TestGeoipOffDuringAPublishIs409(t *testing.T) {
332 withFixtureProbes(t)
333 fx := newConsoleFixture(t)
334 owner := cliToken(t, fx, "paul@keymail.dev", "code-geoip-offbusy")
335 st, err := fx.srv.geoip.Stage(context.Background(), bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")))
336 if err != nil {
337 t.Fatal(err)
338 }
339 defer fx.srv.geoip.Abandon(st)
340 req, _ := http.NewRequest(http.MethodDelete, fx.ts.URL+"/api/cli/system/geoip", nil)
341 req.Header.Set("Authorization", "Bearer "+owner)
342 done := make(chan int, 1)
343 go func() {
344 res, err := http.DefaultClient.Do(req)
345 if err != nil {
346 done <- 0
347 return
348 }
349 res.Body.Close()
350 done <- res.StatusCode
351 }()
352 select {
353 case code := <-done:
354 if code != http.StatusConflict {
355 t.Fatalf("off during a publish = %d, want 409", code)
356 }
357 case <-time.After(5 * time.Second):
358 t.Fatal("off waited behind the publish")
359 }
360}
361
362// Codex R10 on R9-1: a pointer past D1 by the clock is "off" for the
363// verdict even before the daily job marks it.
364func TestGeoipStatusTreatsAnUnmarkedExpiredPointerAsOff(t *testing.T) {
365 fx := newConsoleFixture(t)
366 owner := cliToken(t, fx, "paul@keymail.dev", "code-geoip-expired")
367 sha := strings.Repeat("c", 64)
368 ptr := geoip.Pointer{V: 1, SHA256: sha, Key: geoip.BlobKey(sha), Size: 1, DatabaseType: "GeoLite2-City",
369 BuildEpoch: time.Now().Add(-40 * 24 * time.Hour).Unix(), Origin: "manual"}
370 b, _ := marshalPointer(ptr)
371 if _, err := fx.rel.Put(context.Background(), geoip.PointerKey, b, ""); err != nil {
372 t.Fatal(err)
373 }
374 seedEdgesRecord(t, fx, "ie-1")
375 seedGeoBox(t, fx, "ie-1", &status.GeoIPState{Mode: "mirror", SHA256: sha}, time.Now())
376 _, out := getJSON(t, fx.ts.URL+"/api/cli/system/geoip", owner)
377 if out["ready"] != false || !strings.Contains(fmt.Sprint(out["blocking"]), "still has") {
378 t.Fatalf("expired pointer with a loaded mirror = %v", out)
379 }
380 seedGeoBox(t, fx, "ie-1", &status.GeoIPState{Mode: "mirror"}, time.Now())
381 if _, out = getJSON(t, fx.ts.URL+"/api/cli/system/geoip", owner); out["ready"] != true {
382 t.Fatalf("expired pointer with every mirror empty = %v, want ready", out)
383 }
384}
385