Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,33 @@ func (c *Client) RevokeClient(ctx context.Context, id int) error {
// against a misbehaving `next` chain, set well above any real account.
const maxListPages = 100

// GetClient fetches a single client by its dashboard id (GET /edge-device/{id}/).
// The detail route is the same one PatchClientClusterID/RevokeClient address, and
// returns one ProvisionedClient. This is the O(1) way to check ONE client's
// status — unlike ListClients, which pages through the whole account (the
// home-screen heartbeat must not do that under its ~1.2s budget, cli#338).
// A 404 returns (nil, nil) so the caller can distinguish "no such client" from
// a transport/backend error.
func (c *Client) GetClient(ctx context.Context, id int) (*ProvisionedClient, error) {
path := fmt.Sprintf("/edge-device/%d/", id)
url := c.BaseURL + path
status, raw, err := c.get(ctx, path)
if err != nil {
return nil, err
}
if status == http.StatusNotFound {
return nil, nil
}
if status < 200 || status >= 300 {
return nil, &APIError{StatusCode: status, Body: string(raw), URL: url}
}
var out ProvisionedClient
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("decoding get-client response: %w", err)
}
return &out, nil
}

// ListClients returns ALL clients in the caller's account (GET /edge-device/).
// The endpoint is DRF-paginated, so this follows `next` to the end — list,
// `use <id>`, and create-time collision detection must see every client, not
Expand Down
47 changes: 47 additions & 0 deletions internal/api/client_get_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package api

import (
"context"
"net/http"
"testing"
)

// TestGetClient covers the single-client detail fetch (GET /edge-device/{id}/)
// that backs the home-screen heartbeat (cli#338): a 2xx decodes one client, a
// 404 is (nil, nil) — "no such client", not an error — and any other non-2xx is
// an APIError. It also pins the path so the heartbeat can't regress to a list.
func TestGetClient(t *testing.T) {
t.Run("2xx decodes a single client + hits the detail path", func(t *testing.T) {
c := stubClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/edge-device/1070/" {
t.Errorf("path = %q, want /edge-device/1070/ (detail route, not the list)", r.URL.Path)
}
_, _ = w.Write([]byte(`{"id":1070,"first_name":"asad-macbook","status":1,"namespace":"asad-macbook-3"}`))
})
pc, err := c.GetClient(context.Background(), 1070)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pc == nil || pc.ID != 1070 || pc.Status != 1 {
t.Fatalf("GetClient = %+v, want id=1070 status=1", pc)
}
})

t.Run("404 -> (nil, nil), not an error", func(t *testing.T) {
c := stubClient(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) })
pc, err := c.GetClient(context.Background(), 999)
if pc != nil || err != nil {
t.Fatalf("404 must be (nil, nil); got (%+v, %v)", pc, err)
}
})

t.Run("non-2xx (500) -> APIError", func(t *testing.T) {
c := stubClient(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"detail":"boom"}`))
})
if _, err := c.GetClient(context.Background(), 1); err == nil {
t.Error("a 500 must return an error")
}
})
}
39 changes: 20 additions & 19 deletions internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -864,30 +864,31 @@ func runClientStatus(ctx context.Context, p *ui.Printer, wait bool, timeout time
}
}

// lookupClientStatus finds the account client whose numeric id matches active and
// returns its backend status code. found=false means no such client (deleted, or
// signed into the wrong account). A list error is returned verbatim so --wait can
// treat it as transient and retry.
// lookupClientStatus fetches the active client directly and returns its backend
Comment thread
cursor[bot] marked this conversation as resolved.
// status code. found=false means no such client (deleted, or signed into the
// wrong account). A lookup error is returned verbatim so --wait can treat it as
// transient and retry. Fetches the single client by id (GET /edge-device/{id}/)
// rather than listing the whole account — the home-screen heartbeat runs this
// under a ~1.2s budget, and paging every client blew it (cli#338).
func lookupClientStatus(ctx context.Context, client *api.Client, active string) (status int, found bool, err error) {
clients, err := client.ListClients(ctx)
id, err := strconv.Atoi(active)
if err != nil {
return 0, false, err
// A non-numeric active id can never match a backend client, so report it
// as not-found — exactly what the old ListClients+match path did — rather
// than a permanent error. A --wait loop fail-fasts on a missing client but
// treats errors as transient, so returning an error here would make it
// poll a permanent parse failure to the timeout (Bugbot: poll/retry loops
// must fail-fast on non-transient errors).
return 0, false, nil
}
if c := findClientByID(clients, active); c != nil {
return c.Status, true, nil
c, err := client.GetClient(ctx, id)
if err != nil {
return 0, false, err
}
return 0, false, nil
}

// findClientByID returns the account client whose numeric dashboard id equals id
// (the string form stored as the active-client pointer), or nil if none match.
func findClientByID(clients []api.ProvisionedClient, id string) *api.ProvisionedClient {
for i := range clients {
if strconv.Itoa(clients[i].ID) == id {
return &clients[i]
}
if c == nil {
return 0, false, nil // 404 — no such client
}
return nil
return c.Status, true, nil
}

// EdgeDevice.status codes mirrored from the backend (metaApi User.py).
Expand Down
55 changes: 39 additions & 16 deletions internal/cli/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1223,8 +1223,8 @@ func setActiveClientID(t *testing.T, id string) {
// reports the active client online, and says so.
func TestClientStatus_WaitOnline_Exit0(t *testing.T) {
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" {
_, _ = w.Write([]byte(`[{"id":5,"first_name":"c","namespace":"c","status":1}]`)) // 1 = online
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" {
_, _ = w.Write([]byte(`{"id":5,"first_name":"c","namespace":"c","status":1}`)) // 1 = online
return
}
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
Expand All @@ -1245,8 +1245,8 @@ func TestClientStatus_WaitOnline_Exit0(t *testing.T) {
// so the loop never sleeps.
func TestClientStatus_WaitTimeout_Exit1(t *testing.T) {
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" {
_, _ = w.Write([]byte(`[{"id":5,"first_name":"c","namespace":"c","status":0}]`)) // 0 = offline
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" {
_, _ = w.Write([]byte(`{"id":5,"first_name":"c","namespace":"c","status":0}`)) // 0 = offline
}
})
setActiveClientID(t, "5")
Expand All @@ -1265,8 +1265,8 @@ func TestClientStatus_WaitTimeout_Exit1(t *testing.T) {
// TestClientStatus_OneShot: without --wait, report the current state and exit 0.
func TestClientStatus_OneShot(t *testing.T) {
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" {
_, _ = w.Write([]byte(`[{"id":5,"first_name":"c","namespace":"c","status":2}]`)) // 2 = pending
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" {
_, _ = w.Write([]byte(`{"id":5,"first_name":"c","namespace":"c","status":2}`)) // 2 = pending
}
})
setActiveClientID(t, "5")
Expand Down Expand Up @@ -1297,7 +1297,7 @@ func TestClientStatus_NoActiveClient(t *testing.T) {
// proves we didn't poll to exhaustion.
func TestClientStatus_WaitFailsFastOn426(t *testing.T) {
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" {
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" {
w.WriteHeader(http.StatusUpgradeRequired) // 426
_, _ = w.Write([]byte(`{"error":"upgrade_required","min_version":"1.2.3"}`))
}
Expand All @@ -1320,8 +1320,8 @@ func TestClientStatus_WaitFailsFastOn426(t *testing.T) {
// matching the one-shot path, rather than polling to the timeout.
func TestClientStatus_WaitFailsFastOnMissingClient(t *testing.T) {
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" {
_, _ = w.Write([]byte(`[{"id":9,"first_name":"other","namespace":"other","status":1}]`)) // active id 5 absent
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" {
w.WriteHeader(http.StatusNotFound) // active id 5 absent → 404
}
})
setActiveClientID(t, "5")
Expand All @@ -1335,13 +1335,36 @@ func TestClientStatus_WaitFailsFastOnMissingClient(t *testing.T) {
}
}

// TestClientStatus_WaitFailsFastOnNonNumericID (Bugbot, #338 follow-up): a
// corrupt (non-numeric) active client id can never match a backend client, so
// --wait must fail fast on a missing client rather than retry a permanent parse
// error to the timeout. No backend call is expected (the id never parses).
func TestClientStatus_WaitFailsFastOnNonNumericID(t *testing.T) {
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("no backend call expected for a non-numeric id, got %s %s", r.Method, r.URL.Path)
})
setActiveClientID(t, "not-a-number")
// Long timeout: the test would hang if a non-numeric id were treated as a
// transient error instead of a missing client.
err := runClientStatus(context.Background(), ui.New(&bytes.Buffer{}), true, 10*time.Minute)
if got := ExitCodeFromError(err); got != 1 {
t.Fatalf("exit code = %d, want 1", got)
}
if err != nil && strings.Contains(err.Error(), "timed out") {
t.Errorf("a non-numeric id must fail fast, not time out: %v", err)
}
if err == nil || !strings.Contains(err.Error(), "isn't in your account") {
t.Errorf("want a missing-client error, got: %v", err)
}
}

// TestClientStatus_WaitTimeoutSurfacesListError (Bugbot #146-F): when every
// status check fails, the timeout message must name the real error, not a bare
// "unreachable". A 1ns timeout means the deadline passes on the first failure.
func TestClientStatus_WaitTimeoutSurfacesListError(t *testing.T) {
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" {
w.WriteHeader(http.StatusInternalServerError) // persistent list failure
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" {
w.WriteHeader(http.StatusInternalServerError) // persistent status-check failure
}
})
setActiveClientID(t, "5")
Expand All @@ -1362,7 +1385,7 @@ func TestClientStatus_WaitTimeoutSurfacesListError(t *testing.T) {
// the full timeout. A 10-minute timeout would hang the test if it didn't.
func TestClientStatus_WaitFailsFastOn401(t *testing.T) {
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" {
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" {
w.WriteHeader(http.StatusUnauthorized) // dead credential
}
})
Expand Down Expand Up @@ -1398,13 +1421,13 @@ func TestClientStatus_TimeoutWithoutWaitRejected(t *testing.T) {
func TestClientStatus_WaitTimeoutClearsStaleError(t *testing.T) {
calls := 0
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" {
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" {
calls++
if calls == 1 {
w.WriteHeader(http.StatusBadGateway) // one transient blip
return
}
_, _ = w.Write([]byte(`[{"id":5,"first_name":"c","namespace":"c","status":0}]`)) // then offline
_, _ = w.Write([]byte(`{"id":5,"first_name":"c","namespace":"c","status":0}`)) // then offline
}
})
setActiveClientID(t, "5")
Expand All @@ -1430,8 +1453,8 @@ func TestClientStatus_WaitTimeoutClearsStaleError(t *testing.T) {
// during --wait exits quietly with code 130 — not a bare "Error: context canceled".
func TestClientStatus_WaitCtrlCIsSilent(t *testing.T) {
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/" {
_, _ = w.Write([]byte(`[{"id":5,"first_name":"c","namespace":"c","status":0}]`)) // offline
if r.Method == http.MethodGet && r.URL.Path == "/edge-device/5/" {
_, _ = w.Write([]byte(`{"id":5,"first_name":"c","namespace":"c","status":0}`)) // offline
}
})
setActiveClientID(t, "5")
Expand Down
Loading
Loading