carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package fleetapi_test
2
3// Round-trip tests against the REAL store door (internal/edge/storedoor.go,
4// Task 2) over a real net.Listener + release.DirStore — no hand-rolled fake
5// speaks for the wire here. The door is reached by wiring a full edge.Edge
6// the same way production does (registry fleet/box rows, EnableStatic,
7// EnableTunnels, Reload) and pointing the hub's reserved hostname at the
8// listener's own address, so an ordinary HTTP client naturally sends the
9// Host header the door's routing (edge.go: r.Host == hub.Host(), port
10// stripped) requires — no Host-header override needed, the same shape a real
11// box dialing CARLOS_TUNNEL_URL would produce.
12
13import (
14 "bytes"
15 "context"
16 "crypto/sha256"
17 "encoding/hex"
18 "errors"
19 "io"
20 "net"
21 "net/http"
22 "os"
23 "path/filepath"
24 "strings"
25 "testing"
26
27 "github.com/carlosframework/platform/internal/edge"
28 "github.com/carlosframework/platform/internal/fleetapi"
29 "github.com/carlosframework/platform/internal/registry"
30 "github.com/carlosframework/platform/internal/release"
31)
32
33const (
34 testSqid = "ab12"
35 testFleet = "pi"
36 testLabel = "box-a"
37 testToken = "box-a-bearer-token-abc123"
38)
39
40func sha256Hex(s string) string {
41 sum := sha256.Sum256([]byte(s))
42 return hex.EncodeToString(sum[:])
43}
44
45// testDoor wires a real edge.Edge with the store door armed, over a plain
46// net.Listener whose bound IP becomes the hub's reserved hostname — the
47// production shape (EnableStatic + EnableTunnels + Reload), minus TLS, which
48// the door itself has no opinion about (edge.go terminates that separately).
49type testDoor struct {
50 base string
51 store *release.DirStore
52}
53
54func newTestDoor(t *testing.T) testDoor {
55 t.Helper()
56 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
57 if err != nil {
58 t.Fatalf("registry.Open: %v", err)
59 }
60 if err := reg.UpsertFleet(registry.Fleet{Name: testFleet, Account: testSqid, Bucket: "pi-data"}); err != nil {
61 t.Fatalf("UpsertFleet: %v", err)
62 }
63 if err := reg.AddFleetBox(registry.FleetBox{
64 Fleet: testFleet, Label: testLabel, TokenHash: sha256Hex(testToken), Status: "active",
65 }); err != nil {
66 t.Fatalf("AddFleetBox: %v", err)
67 }
68
69 ln, err := net.Listen("tcp", "127.0.0.1:0")
70 if err != nil {
71 t.Fatalf("listen: %v", err)
72 }
73 t.Cleanup(func() { ln.Close() })
74 host := ln.Addr().(*net.TCPAddr).IP.String() // "127.0.0.1" — edge.go strips the port before comparing
75
76 e, err := edge.New(reg, nil)
77 if err != nil {
78 t.Fatalf("edge.New: %v", err)
79 }
80 dirStore := release.NewDirStore(t.TempDir())
81 e.EnableStatic(dirStore, nil, filepath.Join(t.TempDir(), "cache"))
82 e.EnableTunnels(edge.NewTunnelHub(host, nil))
83 if err := e.Reload(); err != nil {
84 t.Fatalf("Reload: %v", err)
85 }
86
87 srv := &http.Server{Handler: e}
88 go srv.Serve(ln) //nolint:errcheck
89 t.Cleanup(func() { srv.Close() })
90
91 return testDoor{base: "http://" + ln.Addr().String(), store: dirStore}
92}
93
94func (d testDoor) client(t *testing.T, name, token string) release.Store {
95 t.Helper()
96 s, err := fleetapi.New(d.base, name, token)
97 if err != nil {
98 t.Fatalf("fleetapi.New: %v", err)
99 }
100 return s
101}
102
103func (d testDoor) box(t *testing.T) release.Store {
104 return d.client(t, testFleet+"/"+testLabel, testToken)
105}
106
107func seed(t *testing.T, s *release.DirStore, key, body string) string {
108 t.Helper()
109 etag, err := s.Put(context.Background(), key, []byte(body), "")
110 if err != nil {
111 t.Fatalf("seed %s: %v", key, err)
112 }
113 return etag
114}
115
116// ---- New() argument validation --------------------------------------------
117
118func TestNewRefusesEmptyArgs(t *testing.T) {
119 cases := []struct{ base, name, token string }{
120 {"", "pi/box-a", "tok"},
121 {"https://tunnel.example.com", "", "tok"},
122 {"https://tunnel.example.com", "pi/box-a", ""},
123 {"not-a-url", "pi/box-a", "tok"},
124 }
125 for _, c := range cases {
126 if s, err := fleetapi.New(c.base, c.name, c.token); err == nil {
127 t.Errorf("New(%q, %q, %q) = %v, nil; want an error", c.base, c.name, c.token, s)
128 }
129 }
130}
131
132// ---- Get / Put CAS round trip ----------------------------------------------
133
134func TestGetPutCASRoundTrip(t *testing.T) {
135 d := newTestDoor(t)
136 s := d.box(t)
137 ctx := context.Background()
138 key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json"
139
140 // Create-only (ifMatch == "" -> If-None-Match: *).
141 etag1, err := s.Put(ctx, key, []byte(`{"n":1}`), "")
142 if err != nil {
143 t.Fatalf("create-only Put: %v", err)
144 }
145 if etag1 == "" {
146 t.Fatal("create-only Put returned no etag")
147 }
148
149 // Round-trip Get.
150 body, gotEtag, err := s.Get(ctx, key)
151 if err != nil {
152 t.Fatalf("Get: %v", err)
153 }
154 if string(body) != `{"n":1}` {
155 t.Errorf("body = %q, want the stored object", body)
156 }
157 if gotEtag != etag1 {
158 t.Errorf("etag = %q, want %q (etags are opaque, passed through unmodified)", gotEtag, etag1)
159 }
160
161 // Create-only again over an existing object: CAS conflict.
162 if _, err := s.Put(ctx, key, []byte("again"), ""); !errors.Is(err, release.ErrCASConflict) {
163 t.Errorf("create-only Put over existing object: err = %v, want release.ErrCASConflict", err)
164 }
165
166 // Replace with a stale etag: CAS conflict.
167 if _, err := s.Put(ctx, key, []byte("stale"), "0000000000000000000000000000000000000000000000000000000000000000"); !errors.Is(err, release.ErrCASConflict) {
168 t.Errorf("replace with stale etag: err = %v, want release.ErrCASConflict", err)
169 }
170
171 // Replace with the fresh etag: succeeds, new etag differs.
172 etag2, err := s.Put(ctx, key, []byte(`{"n":2}`), gotEtag)
173 if err != nil {
174 t.Fatalf("replace Put: %v", err)
175 }
176 if etag2 == "" || etag2 == etag1 {
177 t.Errorf("replace etag = %q, want a new etag (was %q)", etag2, etag1)
178 }
179 body, _, err = s.Get(ctx, key)
180 if err != nil || string(body) != `{"n":2}` {
181 t.Fatalf("stored body = %q, %v; want {\"n\":2}", body, err)
182 }
183}
184
185// ---- Get missing --------------------------------------------------------
186
187func TestGetMissingIsNotExist(t *testing.T) {
188 d := newTestDoor(t)
189 s := d.box(t)
190 _, _, err := s.Get(context.Background(), "fleets/"+testFleet+"/status/boxes/nosuchbox.json")
191 if !errors.Is(err, os.ErrNotExist) {
192 t.Fatalf("Get missing in-scope key: err = %v, want errors.Is(err, os.ErrNotExist)", err)
193 }
194}
195
196// ---- List / ListDirs ------------------------------------------------------
197
198func TestListAndListDirsRoundTrip(t *testing.T) {
199 d := newTestDoor(t)
200 seed(t, d.store, "apps/"+testSqid+"/web/channels/stable.json", "{}")
201 seed(t, d.store, "apps/"+testSqid+"/web/releases/v1/manifest.json", "{}")
202 seed(t, d.store, "apps/zz99/web/channels/stable.json", "someone else's")
203 s := d.box(t)
204 ctx := context.Background()
205
206 keys, err := s.List(ctx, "apps/")
207 if err != nil {
208 t.Fatalf("List: %v", err)
209 }
210 want := []string{"apps/" + testSqid + "/web/channels/stable.json", "apps/" + testSqid + "/web/releases/v1/manifest.json"}
211 if !equalUnordered(keys, want) {
212 t.Errorf("List(apps/) = %v, want %v (own account only)", keys, want)
213 }
214
215 dirs, err := s.ListDirs(ctx, "apps/")
216 if err != nil {
217 t.Fatalf("ListDirs: %v", err)
218 }
219 if !equalUnordered(dirs, []string{"apps/" + testSqid + "/web/"}) {
220 t.Errorf("ListDirs(apps/) = %v, want [apps/%s/web/]", dirs, testSqid)
221 }
222
223 // An empty listing is [] on the wire, never null, and this client must
224 // decode that into a zero-length (possibly nil) slice a caller can range
225 // over without a nil check.
226 empty, err := s.List(ctx, "apps/"+testSqid+"/web/nosuchapp/")
227 if err != nil {
228 t.Fatalf("List (empty): %v", err)
229 }
230 if len(empty) != 0 {
231 t.Errorf("List of an empty prefix = %v, want empty", empty)
232 }
233
234 // The door 404s a root listing (aclListScope refuses prefix == "") —
235 // this client does not special-case it, so List("") surfaces as a plain
236 // error, not success and not the not-found mapping Get uses.
237 if _, err := s.List(ctx, ""); err == nil {
238 t.Error("List(\"\") = nil error, want the door's refusal to surface")
239 } else if errors.Is(err, os.ErrNotExist) {
240 t.Errorf("List(\"\") = %v; List has no not-found mapping (only Get does)", err)
241 }
242}
243
244func equalUnordered(got, want []string) bool {
245 if len(got) != len(want) {
246 return false
247 }
248 seen := map[string]int{}
249 for _, g := range got {
250 seen[g]++
251 }
252 for _, w := range want {
253 if seen[w] == 0 {
254 return false
255 }
256 seen[w]--
257 }
258 return true
259}
260
261// ---- Delete -----------------------------------------------------------
262
263func TestDeleteIsIdempotent(t *testing.T) {
264 d := newTestDoor(t)
265 key := "fleets/" + testFleet + "/metrics/boxes/" + testLabel + "/raw/1699.json"
266 seed(t, d.store, key, "{}")
267 s := d.box(t)
268 ctx := context.Background()
269
270 for i := 0; i < 2; i++ {
271 if err := s.Delete(ctx, key); err != nil {
272 t.Fatalf("Delete (attempt %d): %v", i+1, err)
273 }
274 }
275 if _, _, err := s.Get(ctx, key); !errors.Is(err, os.ErrNotExist) {
276 t.Fatalf("Get after Delete: err = %v, want os.ErrNotExist", err)
277 }
278}
279
280// TestDeleteWithWrongCredentialErrors pins C1: a 404 on DELETE is only ever
281// a refusal (the door answers 204 for every in-scope key, present or
282// absent — that's where Delete's idempotency lives). A mis-credentialled
283// client must see an error, not silent "success" for a delete that never
284// happened.
285func TestDeleteWithWrongCredentialErrors(t *testing.T) {
286 d := newTestDoor(t)
287 key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json"
288 seed(t, d.store, key, "{}")
289
290 wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token")
291 if err := wrongToken.Delete(context.Background(), key); err == nil {
292 t.Fatal("Delete with wrong token = nil error, want an error (the key must still exist)")
293 }
294 if _, _, err := d.box(t).Get(context.Background(), key); err != nil {
295 t.Fatalf("the refused Delete removed the object anyway: %v", err)
296 }
297
298 otherAccountKey := "apps/zz99/web/channels/stable.json"
299 seed(t, d.store, otherAccountKey, "someone else's")
300 if err := d.box(t).Delete(context.Background(), otherAccountKey); err == nil {
301 t.Fatal("Delete of another tenant's key = nil error, want an error")
302 }
303 if _, _, err := d.store.Get(context.Background(), otherAccountKey); err != nil {
304 t.Fatalf("the refused Delete removed another tenant's object anyway: %v", err)
305 }
306}
307
308// ---- Auth failure is opaque, matching the door -----------------------
309
310// TestAuthFailureSurfacesAsNotFound pins the door's central opacity
311// invariant on the client side: a bad token answers 404, indistinguishable
312// from a missing key, so this client's Get must map it the same way — never
313// a distinct "auth failed" error a caller could branch on to learn it has a
314// bad credential versus a bad key.
315func TestAuthFailureSurfacesAsNotFound(t *testing.T) {
316 d := newTestDoor(t)
317 key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json"
318 seed(t, d.store, key, "{}")
319
320 wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token")
321 if _, _, err := wrongToken.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) {
322 t.Fatalf("Get with wrong token: err = %v, want os.ErrNotExist (opaque, like a missing key)", err)
323 }
324
325 unknownFleet := d.client(t, "nosuch/box", testToken)
326 if _, _, err := unknownFleet.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) {
327 t.Fatalf("Get from an unregistered fleet: err = %v, want os.ErrNotExist", err)
328 }
329}
330
331// ---- token never leaks into an error string ----------------------------
332
333// TestTokenNeverInErrorStrings exercises every failure path this client can
334// produce against a live door and checks none of them echo the bearer
335// token — the credential itself, not just its hash — into the returned
336// error's text.
337func TestTokenNeverInErrorStrings(t *testing.T) {
338 d := newTestDoor(t)
339 key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json"
340 seed(t, d.store, key, "{}")
341 ctx := context.Background()
342
343 wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token")
344 _, _, getErr := wrongToken.Get(ctx, key)
345
346 s := d.box(t)
347 _, putErr := s.Put(ctx, key, []byte("x"), "") // CAS conflict: already exists
348 _, _, missingErr := s.Get(ctx, "fleets/"+testFleet+"/status/boxes/nosuchbox.json")
349 _, listErr := s.List(ctx, "")
350
351 for name, err := range map[string]error{
352 "wrong-token get": getErr,
353 "cas conflict put": putErr,
354 "missing get": missingErr,
355 "refused list": listErr,
356 } {
357 if err == nil {
358 t.Errorf("%s: no error to check (expected one)", name)
359 continue
360 }
361 if strings.Contains(err.Error(), testToken) {
362 t.Errorf("%s: error %q contains the bearer token", name, err.Error())
363 }
364 if strings.Contains(err.Error(), "not-the-real-token") {
365 t.Errorf("%s: error %q contains the wrong-token value", name, err.Error())
366 }
367 }
368}
369
370// ---- Open (streaming reads) ------------------------------------------------
371
372func TestOpenStreamsThroughTheDoor(t *testing.T) {
373 d := newTestDoor(t)
374 body := bytes.Repeat([]byte("x"), 1<<20)
375 seed(t, d.store, "bootstrap/carlos", string(body))
376 c := d.box(t)
377 op, ok := c.(release.Opener)
378 if !ok {
379 t.Fatal("fleetapi store is not an Opener")
380 }
381 rc, _, err := op.Open(context.Background(), "bootstrap/carlos")
382 if err != nil {
383 t.Fatal(err)
384 }
385 defer rc.Close()
386 got, _ := io.ReadAll(rc)
387 if !bytes.Equal(got, body) {
388 t.Fatalf("read %d bytes, want %d", len(got), len(body))
389 }
390 if _, _, err := op.Open(context.Background(), "bootstrap/nothing"); !errors.Is(err, os.ErrNotExist) {
391 t.Fatalf("missing: %v", err)
392 }
393}
394