diff --git a/internal/hover/client.go b/internal/hover/client.go index 56e37d9..28984cd 100644 --- a/internal/hover/client.go +++ b/internal/hover/client.go @@ -18,7 +18,7 @@ import ( const ( hoverHost = "https://www.hover.com" - defaultUserAgent = "wfctl-hover-plugin/0.1 (+https://github.com/GoCodeAlone/workflow-plugin-hover)" + defaultUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" sessionStaleAfter = 1 * time.Hour ) @@ -42,6 +42,8 @@ type Client struct { // NewClient returns a fresh Client. Pass http=nil for an internal // jar-backed http.Client. Tests inject a stub to redirect requests. func NewClient(creds Credentials, httpClient *http.Client) (*Client, error) { + creds.Username = strings.TrimSpace(creds.Username) + creds.Password = strings.TrimRight(creds.Password, "\r\n") if creds.Username == "" || creds.Password == "" { return nil, errors.New("hover: username + password required") } @@ -100,6 +102,7 @@ func (c *Client) ensureLoginLocked(ctx context.Context) error { "username": c.creds.Username, "password": c.creds.Password, "remember": false, + "token": "", }) if err != nil { return fmt.Errorf("hover signin step 1: %w", err) @@ -192,7 +195,10 @@ func (c *Client) postLoginJSON(ctx context.Context, urlStr string, payload map[s } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json;charset=UTF-8") + req.Header.Set("Origin", hoverHost) + req.Header.Set("Referer", hoverHost+"/signin") req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set("X-Requested-With", "XMLHttpRequest") resp, err := c.http.Do(req) if err != nil { return signinResponse{}, err diff --git a/internal/hover/client_test.go b/internal/hover/client_test.go index 50d397b..9787cdd 100644 --- a/internal/hover/client_test.go +++ b/internal/hover/client_test.go @@ -123,6 +123,59 @@ func TestClient_Login_NoMFA(t *testing.T) { } } +func TestClient_Login_UsesBrowserSigninShape(t *testing.T) { + var authBody map[string]any + var headers http.Header + c, srv := newStubClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/signin/auth.json" { + t.Errorf("unexpected hit: %s %s", r.Method, r.URL.Path) + return + } + headers = r.Header.Clone() + if err := json.NewDecoder(r.Body).Decode(&authBody); err != nil { + t.Errorf("decode auth body: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{"status": "completed"}) + }) + defer srv.Close() + + if err := c.Login(context.Background()); err != nil { + t.Fatalf("Login: %v", err) + } + + if _, ok := authBody["token"]; !ok { + t.Fatalf("auth body missing token field: %#v", authBody) + } + if got := headers.Get("Origin"); got != hoverHost { + t.Errorf("Origin = %q, want %q", got, hoverHost) + } + if got := headers.Get("Referer"); got != hoverHost+"/signin" { + t.Errorf("Referer = %q, want %q", got, hoverHost+"/signin") + } + if got := headers.Get("X-Requested-With"); got != "XMLHttpRequest" { + t.Errorf("X-Requested-With = %q, want XMLHttpRequest", got) + } + if got := headers.Get("User-Agent"); !strings.Contains(got, "Mozilla/5.0") { + t.Errorf("User-Agent = %q, want browser-like UA", got) + } +} + +func TestNewClient_NormalizesPastedSecretNewlines(t *testing.T) { + c, err := NewClient(Credentials{ + Username: " alice@example.com \r\n", + Password: "pw\r\n", + }, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if c.creds.Username != "alice@example.com" { + t.Errorf("username = %q, want trimmed address", c.creds.Username) + } + if c.creds.Password != "pw" { + t.Errorf("password = %q, want newline-trimmed password", c.creds.Password) + } +} + func TestClient_Login_SkipsWhenFresh(t *testing.T) { var hits int c, srv := newStubClient(t, func(w http.ResponseWriter, r *http.Request) {