| new file mode 100644 |
| index 000000000..1584a0bcc |
| --- /dev/null |
| +++ b/internal/fleetapi/fleetapi_test.go |
| @@ -0,0 +1,338 @@ |
| +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 ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "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) |
| + } |
| +} |
| + |
| +// ---- 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()) |
| + } |
| + } |
| +} |