diff --git a/pkg/hoverclient/client.go b/pkg/hoverclient/client.go index 2f9be20..e9a07c9 100644 --- a/pkg/hoverclient/client.go +++ b/pkg/hoverclient/client.go @@ -102,7 +102,12 @@ func (c *Client) ensureLoginLocked(ctx context.Context) error { "username": c.creds.Username, "password": c.creds.Password, "remember": false, - "token": "", + // token MUST be JSON null, not "". Hover's signin branches on this + // field for its "magic token" sign-in: a non-null token (even "") + // routes to magic-token validation, which fails an empty token with + // a generic "Invalid username or password." The browser sends null + // for password sign-in; match it exactly (verified via DevTools). + "token": nil, }) if err != nil { return fmt.Errorf("hover signin step 1: %w", err) diff --git a/pkg/hoverclient/client_test.go b/pkg/hoverclient/client_test.go index 71d7e1d..9df89fc 100644 --- a/pkg/hoverclient/client_test.go +++ b/pkg/hoverclient/client_test.go @@ -143,8 +143,16 @@ func TestClient_Login_UsesBrowserSigninShape(t *testing.T) { t.Fatalf("Login: %v", err) } - if _, ok := authBody["token"]; !ok { + // Regression guard (do not weaken to a key-presence check): token MUST + // be JSON null, not "". A non-null token — including the empty string — + // routes Hover's auth.json to its "magic token" sign-in path, which + // fails an empty token with a generic "Invalid username or password" + // even when username + password are correct. Sending "" cost a + // multi-day live debugging session. See client.go ensureLoginLocked. + if v, ok := authBody["token"]; !ok { t.Fatalf("auth body missing token field: %#v", authBody) + } else if v != nil { + t.Fatalf("token must be JSON null, got %#v — a non-null token (incl. \"\") triggers magic-token signin and 401s with valid creds", v) } if got := headers.Get("Origin"); got != hoverHost { t.Errorf("Origin = %q, want %q", got, hoverHost)