From 04652ddb8e16e8e78175326d8b14ddf23e556564 Mon Sep 17 00:00:00 2001 From: gee-m Date: Sun, 15 Feb 2026 12:07:22 +0100 Subject: [PATCH 1/3] fix(client): use correct OAuth credentials and handle gzip responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the API client: 1. authTokenEndpoint() hardcoded client_id as "sleep-client" with an empty client_secret instead of using the actual app credentials (c.ClientID / c.ClientSecret). This causes a 400 from the auth server, which then falls through to legacy login and triggers rate limiting (429) — making the CLI permanently unusable. 2. do() sends Accept-Encoding: gzip but never decompresses the response body, causing JSON decode errors (invalid character 0x1f) on endpoints that return gzipped responses. --- internal/client/eightsleep.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index 55fad67..284af85 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -2,6 +2,7 @@ package client import ( "bytes" + "compress/gzip" "context" "crypto/tls" "encoding/json" @@ -120,8 +121,8 @@ func (c *Client) authTokenEndpoint(ctx context.Context) error { "grant_type": "password", "username": c.Email, "password": c.Password, - "client_id": "sleep-client", - "client_secret": "", + "client_id": c.ClientID, + "client_secret": c.ClientSecret, } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, bytes.NewReader(body)) @@ -288,6 +289,15 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, return err } defer resp.Body.Close() + var bodyReader io.Reader = resp.Body + if resp.Header.Get("Content-Encoding") == "gzip" { + gr, err := gzip.NewReader(resp.Body) + if err != nil { + return err + } + defer gr.Close() + bodyReader = gr + } if resp.StatusCode == http.StatusTooManyRequests { time.Sleep(2 * time.Second) return c.do(ctx, method, path, query, body, out) @@ -301,11 +311,11 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, return c.do(ctx, method, path, query, body, out) } if resp.StatusCode >= 300 { - b, _ := io.ReadAll(resp.Body) + b, _ := io.ReadAll(bodyReader) return fmt.Errorf("api %s %s: %s", method, path, string(b)) } if out != nil { - return json.NewDecoder(resp.Body).Decode(out) + return json.NewDecoder(bodyReader).Decode(out) } return nil } From 2143a58c7b0e80752a6025198f62d6be18f85db3 Mon Sep 17 00:00:00 2001 From: gee-m Date: Sun, 15 Feb 2026 12:11:02 +0100 Subject: [PATCH 2/3] test: add tests for OAuth credentials and gzip response handling - TestAuthTokenEndpointUsesConfiguredCredentials: verifies New() defaults to the app credentials (not 'sleep-client') and that custom creds pass through - TestDoHandlesGzipResponse: verifies gzip Content-Encoding is decompressed - TestDoHandlesPlainResponse: verifies plain responses still work --- internal/client/eightsleep_test.go | 96 ++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/internal/client/eightsleep_test.go b/internal/client/eightsleep_test.go index a6204ac..5f99232 100644 --- a/internal/client/eightsleep_test.go +++ b/internal/client/eightsleep_test.go @@ -1,7 +1,9 @@ package client import ( + "compress/gzip" "context" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -101,3 +103,97 @@ func Test429Retry(t *testing.T) { t.Fatalf("expected backoff, got %v", elapsed) } } + +func TestAuthTokenEndpointUsesConfiguredCredentials(t *testing.T) { + var gotClientID, gotClientSecret string + + mux := http.NewServeMux() + mux.HandleFunc("/v1/tokens", func(w http.ResponseWriter, r *http.Request) { + var payload map[string]string + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode: %v", err) + } + gotClientID = payload["client_id"] + gotClientSecret = payload["client_secret"] + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "test-token", + "expires_in": 3600, + "userId": "uid-1", + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + // Patch authURL for test by using authTokenEndpoint indirectly — + // we can't override the const, so instead test via New() defaults. + c := New("user@test.com", "pass", "", "", "") + + // Verify defaults are the app credentials, not "sleep-client" + if c.ClientID == "sleep-client" || c.ClientID == "" { + t.Fatalf("expected default app client ID, got %q", c.ClientID) + } + if c.ClientSecret == "" { + t.Fatalf("expected default app client secret to be non-empty") + } + + // Also verify custom credentials pass through + c2 := New("user@test.com", "pass", "", "custom-id", "custom-secret") + if c2.ClientID != "custom-id" || c2.ClientSecret != "custom-secret" { + t.Fatalf("custom credentials not preserved: got %q / %q", c2.ClientID, c2.ClientSecret) + } + _ = gotClientID + _ = gotClientSecret +} + +func TestDoHandlesGzipResponse(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/gzipped", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "gzip") + gz := gzip.NewWriter(w) + json.NewEncoder(gz).Encode(map[string]string{"hello": "world"}) + gz.Close() + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New("email", "pass", "uid", "", "") + c.BaseURL = srv.URL + c.token = "t" + c.tokenExp = time.Now().Add(time.Hour) + c.HTTP = srv.Client() + + var out map[string]string + if err := c.do(context.Background(), http.MethodGet, "/gzipped", nil, nil, &out); err != nil { + t.Fatalf("do gzip: %v", err) + } + if out["hello"] != "world" { + t.Fatalf("expected {hello: world}, got %v", out) + } +} + +func TestDoHandlesPlainResponse(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/plain", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"hello": "plain"}) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New("email", "pass", "uid", "", "") + c.BaseURL = srv.URL + c.token = "t" + c.tokenExp = time.Now().Add(time.Hour) + c.HTTP = srv.Client() + + var out map[string]string + if err := c.do(context.Background(), http.MethodGet, "/plain", nil, nil, &out); err != nil { + t.Fatalf("do plain: %v", err) + } + if out["hello"] != "plain" { + t.Fatalf("expected {hello: plain}, got %v", out) + } +} From ede5197a4a82dd8217ad609384eb2bb234de3349 Mon Sep 17 00:00:00 2001 From: gee-m Date: Sun, 15 Feb 2026 12:13:51 +0100 Subject: [PATCH 3/3] test: keep only the gzip test, drop weak credential and redundant plain tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential test only verified that New() assigns struct fields — it couldn't actually test that authTokenEndpoint() sends the right values in the HTTP request because authURL is a package-level const pointing at the real Eight Sleep server. The captured variables were never asserted. The plain response test duplicated coverage already provided by TestRequireUserFilledAutomatically. The gzip test is the only one that exercises a real code path we changed (Content-Encoding: gzip decompression in do()), so keep that. --- internal/client/eightsleep_test.go | 66 ------------------------------ 1 file changed, 66 deletions(-) diff --git a/internal/client/eightsleep_test.go b/internal/client/eightsleep_test.go index 5f99232..610b44f 100644 --- a/internal/client/eightsleep_test.go +++ b/internal/client/eightsleep_test.go @@ -104,49 +104,6 @@ func Test429Retry(t *testing.T) { } } -func TestAuthTokenEndpointUsesConfiguredCredentials(t *testing.T) { - var gotClientID, gotClientSecret string - - mux := http.NewServeMux() - mux.HandleFunc("/v1/tokens", func(w http.ResponseWriter, r *http.Request) { - var payload map[string]string - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode: %v", err) - } - gotClientID = payload["client_id"] - gotClientSecret = payload["client_secret"] - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "access_token": "test-token", - "expires_in": 3600, - "userId": "uid-1", - }) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - // Patch authURL for test by using authTokenEndpoint indirectly — - // we can't override the const, so instead test via New() defaults. - c := New("user@test.com", "pass", "", "", "") - - // Verify defaults are the app credentials, not "sleep-client" - if c.ClientID == "sleep-client" || c.ClientID == "" { - t.Fatalf("expected default app client ID, got %q", c.ClientID) - } - if c.ClientSecret == "" { - t.Fatalf("expected default app client secret to be non-empty") - } - - // Also verify custom credentials pass through - c2 := New("user@test.com", "pass", "", "custom-id", "custom-secret") - if c2.ClientID != "custom-id" || c2.ClientSecret != "custom-secret" { - t.Fatalf("custom credentials not preserved: got %q / %q", c2.ClientID, c2.ClientSecret) - } - _ = gotClientID - _ = gotClientSecret -} - func TestDoHandlesGzipResponse(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/gzipped", func(w http.ResponseWriter, r *http.Request) { @@ -174,26 +131,3 @@ func TestDoHandlesGzipResponse(t *testing.T) { } } -func TestDoHandlesPlainResponse(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/plain", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"hello": "plain"}) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - c := New("email", "pass", "uid", "", "") - c.BaseURL = srv.URL - c.token = "t" - c.tokenExp = time.Now().Add(time.Hour) - c.HTTP = srv.Client() - - var out map[string]string - if err := c.do(context.Background(), http.MethodGet, "/plain", nil, nil, &out); err != nil { - t.Fatalf("do plain: %v", err) - } - if out["hello"] != "plain" { - t.Fatalf("expected {hello: plain}, got %v", out) - } -}