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
8 changes: 7 additions & 1 deletion internal/hover/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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")
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions internal/hover/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down