package fleetapi_test // Round-trip tests against the REAL store door (internal/edge/storedoor.go, // Task 2) over a real net.Listener + release.DirStore — no hand-rolled fake // speaks for the wire here. The door is reached by wiring a full edge.Edge // the same way production does (registry fleet/box rows, EnableStatic, // EnableTunnels, Reload) and pointing the hub's reserved hostname at the // listener's own address, so an ordinary HTTP client naturally sends the // Host header the door's routing (edge.go: r.Host == hub.Host(), port // stripped) requires — no Host-header override needed, the same shape a real // box dialing CARLOS_TUNNEL_URL would produce. import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "io" "net" "net/http" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/edge" "github.com/carlosframework/platform/internal/fleetapi" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) const ( testSqid = "ab12" testFleet = "pi" testLabel = "box-a" testToken = "box-a-bearer-token-abc123" ) func sha256Hex(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:]) } // testDoor wires a real edge.Edge with the store door armed, over a plain // net.Listener whose bound IP becomes the hub's reserved hostname — the // production shape (EnableStatic + EnableTunnels + Reload), minus TLS, which // the door itself has no opinion about (edge.go terminates that separately). type testDoor struct { base string store *release.DirStore } func newTestDoor(t *testing.T) testDoor { t.Helper() reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatalf("registry.Open: %v", err) } if err := reg.UpsertFleet(registry.Fleet{Name: testFleet, Account: testSqid, Bucket: "pi-data"}); err != nil { t.Fatalf("UpsertFleet: %v", err) } if err := reg.AddFleetBox(registry.FleetBox{ Fleet: testFleet, Label: testLabel, TokenHash: sha256Hex(testToken), Status: "active", }); err != nil { t.Fatalf("AddFleetBox: %v", err) } ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } t.Cleanup(func() { ln.Close() }) host := ln.Addr().(*net.TCPAddr).IP.String() // "127.0.0.1" — edge.go strips the port before comparing e, err := edge.New(reg, nil) if err != nil { t.Fatalf("edge.New: %v", err) } dirStore := release.NewDirStore(t.TempDir()) e.EnableStatic(dirStore, nil, filepath.Join(t.TempDir(), "cache")) e.EnableTunnels(edge.NewTunnelHub(host, nil)) if err := e.Reload(); err != nil { t.Fatalf("Reload: %v", err) } srv := &http.Server{Handler: e} go srv.Serve(ln) //nolint:errcheck t.Cleanup(func() { srv.Close() }) return testDoor{base: "http://" + ln.Addr().String(), store: dirStore} } func (d testDoor) client(t *testing.T, name, token string) release.Store { t.Helper() s, err := fleetapi.New(d.base, name, token) if err != nil { t.Fatalf("fleetapi.New: %v", err) } return s } func (d testDoor) box(t *testing.T) release.Store { return d.client(t, testFleet+"/"+testLabel, testToken) } func seed(t *testing.T, s *release.DirStore, key, body string) string { t.Helper() etag, err := s.Put(context.Background(), key, []byte(body), "") if err != nil { t.Fatalf("seed %s: %v", key, err) } return etag } // ---- New() argument validation -------------------------------------------- func TestNewRefusesEmptyArgs(t *testing.T) { cases := []struct{ base, name, token string }{ {"", "pi/box-a", "tok"}, {"https://tunnel.example.com", "", "tok"}, {"https://tunnel.example.com", "pi/box-a", ""}, {"not-a-url", "pi/box-a", "tok"}, } for _, c := range cases { if s, err := fleetapi.New(c.base, c.name, c.token); err == nil { t.Errorf("New(%q, %q, %q) = %v, nil; want an error", c.base, c.name, c.token, s) } } } // ---- Get / Put CAS round trip ---------------------------------------------- func TestGetPutCASRoundTrip(t *testing.T) { d := newTestDoor(t) s := d.box(t) ctx := context.Background() key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json" // Create-only (ifMatch == "" -> If-None-Match: *). etag1, err := s.Put(ctx, key, []byte(`{"n":1}`), "") if err != nil { t.Fatalf("create-only Put: %v", err) } if etag1 == "" { t.Fatal("create-only Put returned no etag") } // Round-trip Get. body, gotEtag, err := s.Get(ctx, key) if err != nil { t.Fatalf("Get: %v", err) } if string(body) != `{"n":1}` { t.Errorf("body = %q, want the stored object", body) } if gotEtag != etag1 { t.Errorf("etag = %q, want %q (etags are opaque, passed through unmodified)", gotEtag, etag1) } // Create-only again over an existing object: CAS conflict. if _, err := s.Put(ctx, key, []byte("again"), ""); !errors.Is(err, release.ErrCASConflict) { t.Errorf("create-only Put over existing object: err = %v, want release.ErrCASConflict", err) } // Replace with a stale etag: CAS conflict. if _, err := s.Put(ctx, key, []byte("stale"), "0000000000000000000000000000000000000000000000000000000000000000"); !errors.Is(err, release.ErrCASConflict) { t.Errorf("replace with stale etag: err = %v, want release.ErrCASConflict", err) } // Replace with the fresh etag: succeeds, new etag differs. etag2, err := s.Put(ctx, key, []byte(`{"n":2}`), gotEtag) if err != nil { t.Fatalf("replace Put: %v", err) } if etag2 == "" || etag2 == etag1 { t.Errorf("replace etag = %q, want a new etag (was %q)", etag2, etag1) } body, _, err = s.Get(ctx, key) if err != nil || string(body) != `{"n":2}` { t.Fatalf("stored body = %q, %v; want {\"n\":2}", body, err) } } // ---- Get missing -------------------------------------------------------- func TestGetMissingIsNotExist(t *testing.T) { d := newTestDoor(t) s := d.box(t) _, _, err := s.Get(context.Background(), "fleets/"+testFleet+"/status/boxes/nosuchbox.json") if !errors.Is(err, os.ErrNotExist) { t.Fatalf("Get missing in-scope key: err = %v, want errors.Is(err, os.ErrNotExist)", err) } } // ---- List / ListDirs ------------------------------------------------------ func TestListAndListDirsRoundTrip(t *testing.T) { d := newTestDoor(t) seed(t, d.store, "apps/"+testSqid+"/web/channels/stable.json", "{}") seed(t, d.store, "apps/"+testSqid+"/web/releases/v1/manifest.json", "{}") seed(t, d.store, "apps/zz99/web/channels/stable.json", "someone else's") s := d.box(t) ctx := context.Background() keys, err := s.List(ctx, "apps/") if err != nil { t.Fatalf("List: %v", err) } want := []string{"apps/" + testSqid + "/web/channels/stable.json", "apps/" + testSqid + "/web/releases/v1/manifest.json"} if !equalUnordered(keys, want) { t.Errorf("List(apps/) = %v, want %v (own account only)", keys, want) } dirs, err := s.ListDirs(ctx, "apps/") if err != nil { t.Fatalf("ListDirs: %v", err) } if !equalUnordered(dirs, []string{"apps/" + testSqid + "/web/"}) { t.Errorf("ListDirs(apps/) = %v, want [apps/%s/web/]", dirs, testSqid) } // An empty listing is [] on the wire, never null, and this client must // decode that into a zero-length (possibly nil) slice a caller can range // over without a nil check. empty, err := s.List(ctx, "apps/"+testSqid+"/web/nosuchapp/") if err != nil { t.Fatalf("List (empty): %v", err) } if len(empty) != 0 { t.Errorf("List of an empty prefix = %v, want empty", empty) } // The door 404s a root listing (aclListScope refuses prefix == "") — // this client does not special-case it, so List("") surfaces as a plain // error, not success and not the not-found mapping Get uses. if _, err := s.List(ctx, ""); err == nil { t.Error("List(\"\") = nil error, want the door's refusal to surface") } else if errors.Is(err, os.ErrNotExist) { t.Errorf("List(\"\") = %v; List has no not-found mapping (only Get does)", err) } } func equalUnordered(got, want []string) bool { if len(got) != len(want) { return false } seen := map[string]int{} for _, g := range got { seen[g]++ } for _, w := range want { if seen[w] == 0 { return false } seen[w]-- } return true } // ---- Delete ----------------------------------------------------------- func TestDeleteIsIdempotent(t *testing.T) { d := newTestDoor(t) key := "fleets/" + testFleet + "/metrics/boxes/" + testLabel + "/raw/1699.json" seed(t, d.store, key, "{}") s := d.box(t) ctx := context.Background() for i := 0; i < 2; i++ { if err := s.Delete(ctx, key); err != nil { t.Fatalf("Delete (attempt %d): %v", i+1, err) } } if _, _, err := s.Get(ctx, key); !errors.Is(err, os.ErrNotExist) { t.Fatalf("Get after Delete: err = %v, want os.ErrNotExist", err) } } // TestDeleteWithWrongCredentialErrors pins C1: a 404 on DELETE is only ever // a refusal (the door answers 204 for every in-scope key, present or // absent — that's where Delete's idempotency lives). A mis-credentialled // client must see an error, not silent "success" for a delete that never // happened. func TestDeleteWithWrongCredentialErrors(t *testing.T) { d := newTestDoor(t) key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json" seed(t, d.store, key, "{}") wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token") if err := wrongToken.Delete(context.Background(), key); err == nil { t.Fatal("Delete with wrong token = nil error, want an error (the key must still exist)") } if _, _, err := d.box(t).Get(context.Background(), key); err != nil { t.Fatalf("the refused Delete removed the object anyway: %v", err) } otherAccountKey := "apps/zz99/web/channels/stable.json" seed(t, d.store, otherAccountKey, "someone else's") if err := d.box(t).Delete(context.Background(), otherAccountKey); err == nil { t.Fatal("Delete of another tenant's key = nil error, want an error") } if _, _, err := d.store.Get(context.Background(), otherAccountKey); err != nil { t.Fatalf("the refused Delete removed another tenant's object anyway: %v", err) } } // ---- Auth failure is opaque, matching the door ----------------------- // TestAuthFailureSurfacesAsNotFound pins the door's central opacity // invariant on the client side: a bad token answers 404, indistinguishable // from a missing key, so this client's Get must map it the same way — never // a distinct "auth failed" error a caller could branch on to learn it has a // bad credential versus a bad key. func TestAuthFailureSurfacesAsNotFound(t *testing.T) { d := newTestDoor(t) key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json" seed(t, d.store, key, "{}") wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token") if _, _, err := wrongToken.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) { t.Fatalf("Get with wrong token: err = %v, want os.ErrNotExist (opaque, like a missing key)", err) } unknownFleet := d.client(t, "nosuch/box", testToken) if _, _, err := unknownFleet.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) { t.Fatalf("Get from an unregistered fleet: err = %v, want os.ErrNotExist", err) } } // ---- token never leaks into an error string ---------------------------- // TestTokenNeverInErrorStrings exercises every failure path this client can // produce against a live door and checks none of them echo the bearer // token — the credential itself, not just its hash — into the returned // error's text. func TestTokenNeverInErrorStrings(t *testing.T) { d := newTestDoor(t) key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json" seed(t, d.store, key, "{}") ctx := context.Background() wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token") _, _, getErr := wrongToken.Get(ctx, key) s := d.box(t) _, putErr := s.Put(ctx, key, []byte("x"), "") // CAS conflict: already exists _, _, missingErr := s.Get(ctx, "fleets/"+testFleet+"/status/boxes/nosuchbox.json") _, listErr := s.List(ctx, "") for name, err := range map[string]error{ "wrong-token get": getErr, "cas conflict put": putErr, "missing get": missingErr, "refused list": listErr, } { if err == nil { t.Errorf("%s: no error to check (expected one)", name) continue } if strings.Contains(err.Error(), testToken) { t.Errorf("%s: error %q contains the bearer token", name, err.Error()) } if strings.Contains(err.Error(), "not-the-real-token") { t.Errorf("%s: error %q contains the wrong-token value", name, err.Error()) } } } // ---- Open (streaming reads) ------------------------------------------------ func TestOpenStreamsThroughTheDoor(t *testing.T) { d := newTestDoor(t) body := bytes.Repeat([]byte("x"), 1<<20) seed(t, d.store, "bootstrap/carlos", string(body)) c := d.box(t) op, ok := c.(release.Opener) if !ok { t.Fatal("fleetapi store is not an Opener") } rc, _, err := op.Open(context.Background(), "bootstrap/carlos") if err != nil { t.Fatal(err) } defer rc.Close() got, _ := io.ReadAll(rc) if !bytes.Equal(got, body) { t.Fatalf("read %d bytes, want %d", len(got), len(body)) } if _, _, err := op.Open(context.Background(), "bootstrap/nothing"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("missing: %v", err) } }