diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8dd3c6de..3c76008e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,8 +31,8 @@ jobs: go mod tidy diff go.mod go.mod.bak && diff go.sum go.sum.bak - - name: Run tests - run: go test -v ./... + - name: Run tests with coverage floor + run: make coverage - name: Build run: go build -o bin/hey ./cmd/hey diff --git a/.gitignore b/.gitignore index 523abae3..74e96f7b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ dist/ completions/ profiles/ benchmarks-*.txt +coverage.out +coverage.func.txt +coverage.packages.txt *.test .release-extra/ result diff --git a/Makefile b/Makefile index 998f5484..4c52a7a9 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,13 @@ -.PHONY: build test test-unit test-smoke fmt fmt-check vet lint tidy tidy-check \ +.PHONY: build test test-unit test-smoke coverage fmt fmt-check vet lint tidy tidy-check \ race-test vuln secrets replace-check check-toolchain check security \ release-check release bench bench-save bench-compare \ check-surface check-surface-compat tools clean install help BINARY := $(CURDIR)/bin/hey +COVERAGE_FLOOR ?= 70.8 +COVERAGE_PROFILE ?= coverage.out +COVERAGE_FUNCTIONS ?= coverage.func.txt +COVERAGE_PACKAGES ?= coverage.packages.txt # Local builds are "dev": a git-describe SHA would make them look like releases. VERSION ?= dev LDFLAGS := -s -w \ @@ -19,6 +23,7 @@ help: @echo " make test-unit Run unit tests" @echo " make test Alias for test-unit" @echo " make test-smoke Run smoke tests against a live server" + @echo " make coverage Run cross-package coverage and enforce the 70.8% floor" @echo " make clean Remove build artifacts" @echo " make tidy Tidy dependencies" @echo "" @@ -70,6 +75,12 @@ test-unit: check-toolchain # Alias for test-unit test: test-unit +# Run repository-wide cross-package statement coverage and enforce the regression floor. +coverage: check-toolchain + HEY_NO_KEYRING=1 GOWORK=off go test ./... -coverpkg=./... -covermode=atomic -coverprofile=$(COVERAGE_PROFILE) + @./scripts/coverage-summary.sh $(COVERAGE_PROFILE) $(COVERAGE_FUNCTIONS) $(COVERAGE_PACKAGES) + @./scripts/check-coverage.sh $(COVERAGE_PROFILE) $(COVERAGE_FLOOR) + # Run smoke tests against a live HEY server. # Requires: a running server (default http://app.hey.localhost:3003) and Chrome. # Override defaults: make test-smoke HEY_SMOKE_BASE_URL=... HEY_SMOKE_EMAIL=... HEY_SMOKE_PASSWORD=... @@ -198,6 +209,7 @@ tools: # Clean build artifacts clean: rm -rf bin/ + rm -f $(COVERAGE_PROFILE) $(COVERAGE_FUNCTIONS) $(COVERAGE_PACKAGES) go clean # Install binary to /usr/local/bin diff --git a/README.md b/README.md index c148d465..75d2c7ca 100644 --- a/README.md +++ b/README.md @@ -213,12 +213,15 @@ hey skill install # install the skill globally for your agent ## Development ```bash -make build # build binary -make test # run tests -make lint # run golangci-lint -make clean # remove build artifacts +make build # build binary +make test # run tests +make coverage # run cross-package coverage and enforce the 70.8% floor +make lint # run golangci-lint +make clean # remove build artifacts ``` +`make coverage` writes `coverage.out`, `coverage.func.txt`, and `coverage.packages.txt`, then prints a concise package summary and the lowest-covered functions. + ## License This project is licensed under the MIT License. See [LICENSE.md](LICENSE.md) for details. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 4f724420..9f9124de 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -20,20 +20,27 @@ const ( installID = "hey-cli" ) +type callbackWaiter func(context.Context, string, string, string, LoginOptions) (string, error) +type listenerFactory func(context.Context, string, string) (net.Listener, error) + // Manager handles OAuth authentication. type Manager struct { - baseURL string - store *Store - httpClient *http.Client - mu sync.Mutex + baseURL string + store *Store + httpClient *http.Client + callbackWait callbackWaiter + listen listenerFactory + mu sync.Mutex } // NewManager creates a new auth manager. func NewManager(baseURL string, httpClient *http.Client, configDir string) *Manager { + listenConfig := &net.ListenConfig{} return &Manager{ baseURL: normalizeBaseURL(baseURL), store: NewStore(configDir), httpClient: httpClient, + listen: listenConfig.Listen, } } @@ -165,7 +172,11 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error { authURL := u.String() // Start local callback server - code, err := m.waitForCallback(ctx, state, authURL, callbackAddr, opts) + waitForCallback := m.callbackWait + if waitForCallback == nil { + waitForCallback = m.waitForCallback + } + code, err := waitForCallback(ctx, state, authURL, callbackAddr, opts) if err != nil { return err } @@ -267,8 +278,7 @@ func (m *Manager) CredentialKey() string { } func (m *Manager) waitForCallback(ctx context.Context, expectedState, authURL, callbackAddr string, opts LoginOptions) (string, error) { - lc := net.ListenConfig{} - listener, err := lc.Listen(ctx, "tcp", callbackAddr) + listener, err := m.listen(ctx, "tcp", callbackAddr) if err != nil { return "", fmt.Errorf("failed to start callback server: %w", err) } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 64025adb..66b2f4fa 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -3,9 +3,14 @@ package auth import ( "context" "encoding/json" + "errors" "fmt" + "io" + "net" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" "time" ) @@ -85,6 +90,336 @@ func TestNormalizeBaseURL(t *testing.T) { } } +func TestLoginOAuthFlow(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/tokens" { + t.Errorf("path = %q, want /oauth/tokens", r.URL.Path) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if got := r.Form.Get("code"); got != "callback-code" { + t.Errorf("code = %q, want callback-code", got) + } + if got := r.Form.Get("redirect_uri"); got != "http://127.0.0.1:8976/callback" { + t.Errorf("redirect_uri = %q", got) + } + if got := r.Form.Get("code_verifier"); got == "" { + t.Error("code_verifier is empty") + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"access_token":"oauth-access","refresh_token":"oauth-refresh","expires_in":3600}`) + })) + defer server.Close() + + t.Setenv("HEY_TOKEN", "") + mgr := testManager(t, server) + mgr.callbackWait = func(_ context.Context, state, authURL, callbackAddr string, opts LoginOptions) (string, error) { + if state == "" { + t.Error("state is empty") + } + if callbackAddr != "127.0.0.1:8976" { + t.Errorf("callback address = %q", callbackAddr) + } + if !opts.NoBrowser { + t.Error("NoBrowser = false, want true") + } + u, err := url.Parse(authURL) + if err != nil { + t.Fatalf("Parse auth URL: %v", err) + } + if u.Path != "/oauth/authorizations/new" { + t.Errorf("auth path = %q", u.Path) + } + query := u.Query() + want := map[string]string{ + "client_id": oauthClientID, + "grant_type": "authorization_code", + "redirect_uri": "http://127.0.0.1:8976/callback", + "state": state, + "code_challenge_method": "S256", + "install_id": installID, + } + for key, value := range want { + if got := query.Get(key); got != value { + t.Errorf("auth query %s = %q, want %q", key, got, value) + } + } + if query.Get("code_challenge") == "" { + t.Error("code_challenge is empty") + } + return "callback-code", nil + } + + if err := mgr.Login(t.Context(), LoginOptions{NoBrowser: true}); err != nil { + t.Fatalf("Login: %v", err) + } + creds, err := mgr.GetStore().Load(mgr.CredentialKey()) + if err != nil { + t.Fatalf("Load: %v", err) + } + if creds.AccessToken != "oauth-access" || creds.RefreshToken != "oauth-refresh" { + t.Errorf("credentials = %#v", creds) + } + if creds.OAuthType != "oauth" || creds.TokenEndpoint != server.URL+"/oauth/tokens" { + t.Errorf("OAuth metadata = %#v", creds) + } + if creds.ExpiresAt <= time.Now().Unix() { + t.Errorf("ExpiresAt = %d, want future expiry", creds.ExpiresAt) + } +} + +func TestLoginDoesNotSaveCredentialsOnFailure(t *testing.T) { + tests := []struct { + name string + waitErr error + statusCode int + want string + wantCalls int + }{ + {name: "callback failure", waitErr: errors.New("state mismatch"), want: "state mismatch"}, + {name: "exchange failure", statusCode: http.StatusUnauthorized, want: "token exchange failed", wantCalls: 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(tt.statusCode) + _, _ = io.WriteString(w, "denied") + })) + defer server.Close() + + t.Setenv("HEY_TOKEN", "") + mgr := testManager(t, server) + mgr.callbackWait = func(context.Context, string, string, string, LoginOptions) (string, error) { + return "callback-code", tt.waitErr + } + err := mgr.Login(t.Context(), LoginOptions{}) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want substring %q", err, tt.want) + } + if calls != tt.wantCalls { + t.Errorf("requests = %d, want %d", calls, tt.wantCalls) + } + if _, err := mgr.GetStore().Load(mgr.CredentialKey()); err == nil { + t.Fatal("credentials were saved after failed login") + } + }) + } +} + +func TestWaitForCallback(t *testing.T) { + tests := []struct { + name string + query string + wantCode string + wantError string + }{ + {name: "success", query: "?state=expected&code=authorization-code", wantCode: "authorization-code"}, + {name: "OAuth error", query: "?error=access_denied", wantError: "OAuth error: access_denied"}, + {name: "state mismatch", query: "?state=wrong&code=authorization-code", wantError: "state mismatch"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + listenConfig := &net.ListenConfig{} + listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen: %v", err) + } + mgr := NewManager("http://example.test", http.DefaultClient, t.TempDir()) + mgr.listen = func(_ context.Context, network, address string) (net.Listener, error) { + if network != "tcp" || address != "requested-address" { + t.Errorf("listen arguments = %q, %q", network, address) + } + return listener, nil + } + type result struct { + code string + err error + } + resultCh := make(chan result, 1) + go func() { + code, waitErr := mgr.waitForCallback(t.Context(), "expected", "http://example.test/authorize", "requested-address", LoginOptions{NoBrowser: true}) + resultCh <- result{code: code, err: waitErr} + }() + + client := &http.Client{Timeout: 2 * time.Second} + response, err := client.Get("http://" + listener.Addr().String() + "/callback" + tt.query) //nolint:noctx // local one-shot test request + if err != nil { + t.Fatalf("GET callback: %v", err) + } + _ = response.Body.Close() + var got result + select { + case got = <-resultCh: + case <-time.After(2 * time.Second): + t.Fatal("waitForCallback did not return") + } + if got.code != tt.wantCode { + t.Errorf("code = %q, want %q", got.code, tt.wantCode) + } + if tt.wantError == "" && got.err != nil { + t.Fatalf("waitForCallback: %v", got.err) + } + if tt.wantError != "" && (got.err == nil || !strings.Contains(got.err.Error(), tt.wantError)) { + t.Fatalf("error = %v, want substring %q", got.err, tt.wantError) + } + }) + } +} + +func TestWaitForCallbackFailures(t *testing.T) { + t.Run("listen", func(t *testing.T) { + mgr := NewManager("http://example.test", http.DefaultClient, t.TempDir()) + mgr.listen = func(context.Context, string, string) (net.Listener, error) { + return nil, errors.New("address unavailable") + } + _, err := mgr.waitForCallback(t.Context(), "state", "auth-url", "address", LoginOptions{NoBrowser: true}) + if err == nil || !strings.Contains(err.Error(), "failed to start callback server") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("context cancellation", func(t *testing.T) { + listenConfig := &net.ListenConfig{} + listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen: %v", err) + } + mgr := NewManager("http://example.test", http.DefaultClient, t.TempDir()) + mgr.listen = func(context.Context, string, string) (net.Listener, error) { + return listener, nil + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = mgr.waitForCallback(ctx, "state", "auth-url", "address", LoginOptions{NoBrowser: true}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } + }) +} + +func TestLoginWithCookieAuthenticateAndLogout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("authentication should not make an HTTP request") + })) + defer server.Close() + + t.Setenv("HEY_TOKEN", "") + mgr := testManager(t, server) + if err := mgr.LoginWithCookie("cookie-value"); err != nil { + t.Fatalf("LoginWithCookie: %v", err) + } + if !mgr.IsAuthenticated() { + t.Fatal("IsAuthenticated = false after cookie login") + } + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.test/messages", nil) + if err := mgr.AuthenticateRequest(t.Context(), req); err != nil { + t.Fatalf("AuthenticateRequest: %v", err) + } + if got := req.Header.Get("Cookie"); got != "session_token=cookie-value" { + t.Errorf("Cookie = %q, want session_token cookie", got) + } + if got := req.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization = %q, want empty for cookie auth", got) + } + + token, err := mgr.AccessToken(t.Context()) + if err != nil { + t.Fatalf("AccessToken: %v", err) + } + if token != "cookie-value" { + t.Errorf("AccessToken = %q, want cookie fallback", token) + } + if err := mgr.Refresh(t.Context()); err != nil { + t.Fatalf("cookie Refresh: %v", err) + } + if err := mgr.Logout(); err != nil { + t.Fatalf("Logout: %v", err) + } + if mgr.IsAuthenticated() { + t.Fatal("IsAuthenticated = true after logout") + } +} + +func TestAuthenticateRequestBearerPrecedence(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + t.Run("environment", func(t *testing.T) { + t.Setenv("HEY_TOKEN", "environment-token") + mgr := testManager(t, server) + if err := mgr.LoginWithCookie("stored-cookie"); err != nil { + t.Fatalf("LoginWithCookie: %v", err) + } + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.test", nil) + if err := mgr.AuthenticateRequest(t.Context(), req); err != nil { + t.Fatalf("AuthenticateRequest: %v", err) + } + if got := req.Header.Get("Authorization"); got != "Bearer environment-token" { + t.Errorf("Authorization = %q", got) + } + if got := req.Header.Get("Cookie"); got != "" { + t.Errorf("Cookie = %q, want empty", got) + } + }) + + t.Run("stored access token", func(t *testing.T) { + t.Setenv("HEY_TOKEN", "") + mgr := testManager(t, server) + creds := &Credentials{AccessToken: "stored-token", SessionCookie: "stored-cookie"} + if err := mgr.GetStore().Save(mgr.CredentialKey(), creds); err != nil { + t.Fatalf("Save: %v", err) + } + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.test", nil) + if err := mgr.AuthenticateRequest(t.Context(), req); err != nil { + t.Fatalf("AuthenticateRequest: %v", err) + } + if got := req.Header.Get("Authorization"); got != "Bearer stored-token" { + t.Errorf("Authorization = %q", got) + } + if got := req.Header.Get("Cookie"); got != "" { + t.Errorf("Cookie = %q, want empty when bearer is available", got) + } + }) +} + +func TestMissingCredentialsDoNotModifyRequest(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + tests := []struct { + name string + creds *Credentials + want string + }{ + {name: "not logged in", want: "not authenticated"}, + {name: "empty credentials", creds: &Credentials{}, want: "no access token or session cookie"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("HEY_TOKEN", "") + mgr := testManager(t, server) + if tt.creds != nil { + if err := mgr.GetStore().Save(mgr.CredentialKey(), tt.creds); err != nil { + t.Fatalf("Save: %v", err) + } + } + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.test", nil) + req.Header.Set("Authorization", "original") + err := mgr.AuthenticateRequest(t.Context(), req) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want substring %q", err, tt.want) + } + if got := req.Header.Get("Authorization"); got != "original" { + t.Errorf("Authorization = %q, want original header preserved", got) + } + }) + } +} + func TestTokenRefreshOnExpiry(t *testing.T) { refreshCalls := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -129,3 +464,131 @@ func TestTokenRefreshOnExpiry(t *testing.T) { t.Errorf("refresh calls = %d, want 1", refreshCalls) } } + +func TestAuthenticateRequestRefreshesAndPreservesRefreshToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/tokens" { + t.Errorf("path = %q, want /oauth/tokens", r.URL.Path) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if got := r.Form.Get("refresh_token"); got != "keep-refresh" { + t.Errorf("refresh_token = %q, want keep-refresh", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"access_token":"fresh-access","expires_in":7200}`) + })) + defer server.Close() + + t.Setenv("HEY_TOKEN", "") + mgr := testManager(t, server) + creds := &Credentials{ + AccessToken: "expired-access", + RefreshToken: "keep-refresh", + ExpiresAt: time.Now().Add(-time.Hour).Unix(), + } + if err := mgr.GetStore().Save(mgr.CredentialKey(), creds); err != nil { + t.Fatalf("Save: %v", err) + } + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.test", nil) + if err := mgr.AuthenticateRequest(t.Context(), req); err != nil { + t.Fatalf("AuthenticateRequest: %v", err) + } + if got := req.Header.Get("Authorization"); got != "Bearer fresh-access" { + t.Errorf("Authorization = %q", got) + } + stored, err := mgr.GetStore().Load(mgr.CredentialKey()) + if err != nil { + t.Fatalf("Load: %v", err) + } + if stored.RefreshToken != "keep-refresh" { + t.Errorf("RefreshToken = %q, want preserved token", stored.RefreshToken) + } + if stored.ExpiresAt <= time.Now().Unix() { + t.Errorf("ExpiresAt = %d, want future expiry", stored.ExpiresAt) + } +} + +func TestRefreshUsesStoredEndpointAndRotatesToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/custom-refresh" { + t.Errorf("path = %q, want /custom-refresh", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"access_token":"fresh-access","refresh_token":"rotated-refresh","expires_in":3600}`) + })) + defer server.Close() + + t.Setenv("HEY_TOKEN", "") + mgr := testManager(t, server) + creds := &Credentials{ + AccessToken: "old-access", + RefreshToken: "old-refresh", + TokenEndpoint: server.URL + "/custom-refresh", + } + if err := mgr.GetStore().Save(mgr.CredentialKey(), creds); err != nil { + t.Fatalf("Save: %v", err) + } + if err := mgr.Refresh(t.Context()); err != nil { + t.Fatalf("Refresh: %v", err) + } + stored, err := mgr.GetStore().Load(mgr.CredentialKey()) + if err != nil { + t.Fatalf("Load: %v", err) + } + if stored.AccessToken != "fresh-access" || stored.RefreshToken != "rotated-refresh" { + t.Errorf("stored credentials = %#v", stored) + } +} + +func TestRefreshFailuresPreserveCredentials(t *testing.T) { + tests := []struct { + name string + creds *Credentials + status int + want string + wantCalls int + }{ + {name: "not authenticated", want: "not authenticated", wantCalls: 0}, + {name: "no refresh token", creds: &Credentials{AccessToken: "access"}, want: "no refresh token", wantCalls: 0}, + {name: "server failure", creds: &Credentials{AccessToken: "access", RefreshToken: "refresh"}, status: http.StatusUnauthorized, want: "token refresh failed", wantCalls: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(tt.status) + _, _ = fmt.Fprint(w, "denied") + })) + defer server.Close() + + t.Setenv("HEY_TOKEN", "") + mgr := testManager(t, server) + if tt.creds != nil { + if err := mgr.GetStore().Save(mgr.CredentialKey(), tt.creds); err != nil { + t.Fatalf("Save: %v", err) + } + } + err := mgr.Refresh(t.Context()) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want substring %q", err, tt.want) + } + if calls != tt.wantCalls { + t.Errorf("requests = %d, want %d", calls, tt.wantCalls) + } + if tt.creds != nil { + stored, loadErr := mgr.GetStore().Load(mgr.CredentialKey()) + if loadErr != nil { + t.Fatalf("Load: %v", loadErr) + } + if stored.AccessToken != tt.creds.AccessToken || stored.RefreshToken != tt.creds.RefreshToken { + t.Errorf("credentials changed after failed refresh: %#v", stored) + } + } + }) + } +} diff --git a/internal/auth/oauth_test.go b/internal/auth/oauth_test.go new file mode 100644 index 00000000..245313b6 --- /dev/null +++ b/internal/auth/oauth_test.go @@ -0,0 +1,202 @@ +package auth + +import ( + "context" + "encoding/base64" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestExchangeCodeRequest(t *testing.T) { + before := time.Now() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if got := r.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %q, want form encoding", got) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + want := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {"client-id"}, + "code": {"authorization-code"}, + "redirect_uri": {"http://127.0.0.1/callback"}, + "code_verifier": {"verifier"}, + "install_id": {"installation"}, + } + for key, values := range want { + if got := r.Form[key]; len(got) != 1 || got[0] != values[0] { + t.Errorf("form[%q] = %q, want %q", key, got, values) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"access_token":"access","refresh_token":"refresh","token_type":"Bearer","expires_in":3600}`) + })) + defer server.Close() + + token, err := exchangeCode(t.Context(), server.Client(), server.URL, "authorization-code", "http://127.0.0.1/callback", "client-id", "verifier", "installation") + if err != nil { + t.Fatalf("exchangeCode: %v", err) + } + if token.AccessToken != "access" || token.RefreshToken != "refresh" || token.TokenType != "Bearer" { + t.Errorf("token = %#v", token) + } + if token.ExpiresAt.Before(before.Add(3599*time.Second)) || token.ExpiresAt.After(time.Now().Add(3601*time.Second)) { + t.Errorf("ExpiresAt = %s, want about one hour from now", token.ExpiresAt) + } +} + +func TestRefreshOAuthTokenRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + want := url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {"client-id"}, + "refresh_token": {"old-refresh"}, + "install_id": {"installation"}, + } + for key, values := range want { + if got := r.Form[key]; len(got) != 1 || got[0] != values[0] { + t.Errorf("form[%q] = %q, want %q", key, got, values) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"access_token":"new-access"}`) + })) + defer server.Close() + + token, err := refreshOAuthToken(t.Context(), server.Client(), server.URL, "old-refresh", "client-id", "installation") + if err != nil { + t.Fatalf("refreshOAuthToken: %v", err) + } + if token.AccessToken != "new-access" { + t.Errorf("AccessToken = %q, want new-access", token.AccessToken) + } + if !token.ExpiresAt.IsZero() { + t.Errorf("ExpiresAt = %s, want zero without expires_in", token.ExpiresAt) + } +} + +func TestOAuthTokenResponseFailures(t *testing.T) { + tests := []struct { + name string + status int + body string + want string + exchange bool + }{ + {name: "exchange status", status: http.StatusUnauthorized, body: "denied", want: "token exchange failed (status 401): denied", exchange: true}, + {name: "exchange invalid JSON", status: http.StatusOK, body: "not-json", want: "parsing token response", exchange: true}, + {name: "refresh status", status: http.StatusBadGateway, body: "upstream unavailable", want: "token refresh failed (status 502): upstream unavailable"}, + {name: "refresh invalid JSON", status: http.StatusOK, body: "not-json", want: "parsing refresh response"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.status) + _, _ = io.WriteString(w, tt.body) + })) + defer server.Close() + + var err error + if tt.exchange { + _, err = exchangeCode(t.Context(), server.Client(), server.URL, "code", "redirect", "client", "verifier", "install") + } else { + _, err = refreshOAuthToken(t.Context(), server.Client(), server.URL, "refresh", "client", "install") + } + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want substring %q", err, tt.want) + } + }) + } +} + +type failingRoundTripper struct { + err error +} + +func (f failingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, f.err +} + +func TestOAuthTransportAndRequestFailures(t *testing.T) { + transportErr := errors.New("connection refused") + client := &http.Client{Transport: failingRoundTripper{err: transportErr}} + + if _, err := exchangeCode(t.Context(), client, "http://example.test/token", "code", "redirect", "client", "verifier", "install"); err == nil || !strings.Contains(err.Error(), "token exchange request failed") { + t.Fatalf("exchange error = %v", err) + } + if _, err := refreshOAuthToken(t.Context(), client, "http://example.test/token", "refresh", "client", "install"); err == nil || !strings.Contains(err.Error(), "token refresh request failed") { + t.Fatalf("refresh error = %v", err) + } + + if _, err := exchangeCode(context.Background(), client, "://bad-url", "code", "redirect", "client", "verifier", "install"); err == nil || !strings.Contains(err.Error(), "creating token request") { + t.Fatalf("invalid exchange endpoint error = %v", err) + } + if _, err := refreshOAuthToken(context.Background(), client, "://bad-url", "refresh", "client", "install"); err == nil || !strings.Contains(err.Error(), "creating refresh request") { + t.Fatalf("invalid refresh endpoint error = %v", err) + } +} + +func TestOAuthContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := exchangeCode(ctx, server.Client(), server.URL, "code", "redirect", "client", "verifier", "install") + if err == nil || !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("error = %v, want context canceled", err) + } +} + +func TestPKCEHelpers(t *testing.T) { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + const wantChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + if got := generateCodeChallenge(verifier); got != wantChallenge { + t.Errorf("challenge = %q, want RFC 7636 value %q", got, wantChallenge) + } + + firstVerifier := generateCodeVerifier() + secondVerifier := generateCodeVerifier() + if firstVerifier == secondVerifier { + t.Error("independent verifier generations matched") + } + decodedVerifier, err := base64.RawURLEncoding.DecodeString(firstVerifier) + if err != nil { + t.Fatalf("verifier is not raw URL-safe base64: %v", err) + } + if len(decodedVerifier) != 32 { + t.Errorf("decoded verifier length = %d, want 32", len(decodedVerifier)) + } + + firstState := generateState() + secondState := generateState() + if firstState == secondState { + t.Error("independent state generations matched") + } + decodedState, err := base64.RawURLEncoding.DecodeString(firstState) + if err != nil { + t.Fatalf("state is not raw URL-safe base64: %v", err) + } + if len(decodedState) != 16 { + t.Errorf("decoded state length = %d, want 16", len(decodedState)) + } +} diff --git a/internal/auth/store.go b/internal/auth/store.go index 10af2ce3..7cfebc60 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -13,6 +13,12 @@ import ( const serviceName = "hey" +type credentialKeyring struct { + set func(service, user, password string) error + get func(service, user string) (string, error) + delete func(service, user string) error +} + // Credentials holds OAuth tokens and metadata. type Credentials struct { AccessToken string `json:"access_token"` //nolint:gosec // G117: legitimate credential field @@ -29,6 +35,7 @@ type Store struct { useKeyring bool noKeyring bool fallbackDir string + keyring credentialKeyring } // NewStore creates a credential store. Keyring availability is probed lazily @@ -37,6 +44,11 @@ func NewStore(fallbackDir string) *Store { return &Store{ fallbackDir: fallbackDir, noKeyring: os.Getenv("HEY_NO_KEYRING") != "", + keyring: credentialKeyring{ + set: keyring.Set, + get: keyring.Get, + delete: keyring.Delete, + }, } } @@ -46,9 +58,9 @@ func (s *Store) ensureInit() { return } testKey := "hey::test" - err := keyring.Set(serviceName, testKey, "test") + err := s.keyring.set(serviceName, testKey, "test") if err == nil { - _ = keyring.Delete(serviceName, testKey) + _ = s.keyring.delete(serviceName, testKey) s.useKeyring = true return } @@ -83,13 +95,13 @@ func (s *Store) Save(origin string, creds *Credentials) error { func (s *Store) Delete(origin string) error { s.ensureInit() if s.useKeyring { - return keyring.Delete(serviceName, key(origin)) + return s.keyring.delete(serviceName, key(origin)) } return s.deleteFile(origin) } func (s *Store) loadFromKeyring(origin string) (*Credentials, error) { - data, err := keyring.Get(serviceName, key(origin)) + data, err := s.keyring.get(serviceName, key(origin)) if err != nil { return nil, fmt.Errorf("credentials not found: %w", err) } @@ -106,7 +118,7 @@ func (s *Store) saveToKeyring(origin string, creds *Credentials) error { if err != nil { return err } - return keyring.Set(serviceName, key(origin), string(data)) + return s.keyring.set(serviceName, key(origin), string(data)) } func (s *Store) credentialsPath() string { diff --git a/internal/auth/store_test.go b/internal/auth/store_test.go index 26c92fca..75ebea2c 100644 --- a/internal/auth/store_test.go +++ b/internal/auth/store_test.go @@ -1,8 +1,10 @@ package auth import ( + "errors" "os" "path/filepath" + "strings" "testing" ) @@ -12,6 +14,198 @@ func testStore(t *testing.T) *Store { return NewStore(t.TempDir()) } +type fakeKeyring struct { + values map[string]string + setErr error + getErr error + deleteErr error + failAfterProbe bool + probeComplete bool +} + +func newFakeKeyring() *fakeKeyring { + return &fakeKeyring{values: make(map[string]string)} +} + +func (f *fakeKeyring) Set(service, user, password string) error { + if f.setErr != nil && (!f.failAfterProbe || f.probeComplete) { + return f.setErr + } + f.values[service+"/"+user] = password + if user == "hey::test" { + f.probeComplete = true + } + return nil +} + +func (f *fakeKeyring) Get(service, user string) (string, error) { + if f.getErr != nil { + return "", f.getErr + } + value, ok := f.values[service+"/"+user] + if !ok { + return "", errors.New("not found") + } + return value, nil +} + +func (f *fakeKeyring) Delete(service, user string) error { + if f.deleteErr != nil { + return f.deleteErr + } + delete(f.values, service+"/"+user) + return nil +} + +func keyringStore(t *testing.T, fake *fakeKeyring) *Store { + t.Helper() + t.Setenv("HEY_NO_KEYRING", "") + store := NewStore(t.TempDir()) + store.keyring = credentialKeyring{ + set: fake.Set, + get: fake.Get, + delete: fake.Delete, + } + return store +} + +func TestKeyringSaveLoadDelete(t *testing.T) { + fake := newFakeKeyring() + store := keyringStore(t, fake) + origin := "https://app.hey.com" + creds := &Credentials{AccessToken: "access", RefreshToken: "refresh"} + + if err := store.Save(origin, creds); err != nil { + t.Fatalf("Save: %v", err) + } + if !store.UsingKeyring() { + t.Fatal("UsingKeyring = false after successful probe") + } + loaded, err := store.Load(origin) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.AccessToken != "access" || loaded.RefreshToken != "refresh" { + t.Errorf("credentials = %#v", loaded) + } + if err := store.Delete(origin); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := store.Load(origin); err == nil { + t.Fatal("Load succeeded after keyring deletion") + } +} + +func TestKeyringFailures(t *testing.T) { + t.Run("probe falls back to file", func(t *testing.T) { + fake := newFakeKeyring() + fake.setErr = errors.New("keyring unavailable") + store := keyringStore(t, fake) + if err := store.Save("https://app.hey.com", &Credentials{AccessToken: "file-token"}); err != nil { + t.Fatalf("Save fallback: %v", err) + } + if store.UsingKeyring() { + t.Fatal("UsingKeyring = true after failed probe") + } + if _, err := os.Stat(store.credentialsPath()); err != nil { + t.Fatalf("fallback credentials file: %v", err) + } + }) + + t.Run("invalid stored JSON", func(t *testing.T) { + fake := newFakeKeyring() + store := keyringStore(t, fake) + if !store.UsingKeyring() { + t.Fatal("UsingKeyring = false") + } + fake.values[serviceName+"/"+key("https://app.hey.com")] = "not-json" + _, err := store.Load("https://app.hey.com") + if err == nil || !strings.Contains(err.Error(), "invalid credentials") { + t.Fatalf("error = %v, want invalid credentials", err) + } + }) + + t.Run("get error", func(t *testing.T) { + fake := newFakeKeyring() + store := keyringStore(t, fake) + if !store.UsingKeyring() { + t.Fatal("UsingKeyring = false") + } + fake.getErr = errors.New("locked") + _, err := store.Load("https://app.hey.com") + if err == nil || !strings.Contains(err.Error(), "credentials not found") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("delete error", func(t *testing.T) { + fake := newFakeKeyring() + store := keyringStore(t, fake) + if !store.UsingKeyring() { + t.Fatal("UsingKeyring = false") + } + fake.deleteErr = errors.New("locked") + if err := store.Delete("https://app.hey.com"); err == nil || !strings.Contains(err.Error(), "locked") { + t.Fatalf("Delete error = %v", err) + } + }) +} + +func TestMigrateToKeyring(t *testing.T) { + fake := newFakeKeyring() + store := keyringStore(t, fake) + origins := map[string]*Credentials{ + "https://app.hey.com": {AccessToken: "production"}, + "https://staging.hey.com": {AccessToken: "staging"}, + } + if err := store.saveAllToFile(origins); err != nil { + t.Fatalf("save fallback credentials: %v", err) + } + + if err := store.MigrateToKeyring(); err != nil { + t.Fatalf("MigrateToKeyring: %v", err) + } + if _, err := os.Stat(store.credentialsPath()); !os.IsNotExist(err) { + t.Errorf("credentials file remains after migration: %v", err) + } + for origin, want := range origins { + loaded, err := store.Load(origin) + if err != nil { + t.Fatalf("Load %s: %v", origin, err) + } + if loaded.AccessToken != want.AccessToken { + t.Errorf("%s AccessToken = %q, want %q", origin, loaded.AccessToken, want.AccessToken) + } + } +} + +func TestMigrationFailurePreservesFallbackFile(t *testing.T) { + fake := newFakeKeyring() + fake.setErr = errors.New("keyring write failed") + fake.failAfterProbe = true + store := keyringStore(t, fake) + if err := store.saveAllToFile(map[string]*Credentials{ + "https://app.hey.com": {AccessToken: "production"}, + }); err != nil { + t.Fatalf("save fallback credentials: %v", err) + } + + err := store.MigrateToKeyring() + if err == nil || !strings.Contains(err.Error(), "failed to migrate") { + t.Fatalf("error = %v, want migration failure", err) + } + if _, err := os.Stat(store.credentialsPath()); err != nil { + t.Fatalf("fallback file removed after failed migration: %v", err) + } +} + +func TestMigrationSkipsUnavailableKeyring(t *testing.T) { + store := testStore(t) + if err := store.MigrateToKeyring(); err != nil { + t.Fatalf("MigrateToKeyring: %v", err) + } +} + func TestSaveLoadRoundTrip(t *testing.T) { s := testStore(t) origin := "https://app.hey.com" @@ -97,8 +291,101 @@ func TestMultipleOrigins(t *testing.T) { } } -func TestFilePermissions(t *testing.T) { +func TestDeletePreservesOtherOrigins(t *testing.T) { s := testStore(t) + first := "https://app.hey.com" + second := "https://staging.hey.com" + if err := s.Save(first, &Credentials{AccessToken: "first"}); err != nil { + t.Fatalf("Save first: %v", err) + } + if err := s.Save(second, &Credentials{AccessToken: "second"}); err != nil { + t.Fatalf("Save second: %v", err) + } + + if err := s.Delete(first); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := s.Load(first); err == nil { + t.Fatal("deleted origin still loads") + } + creds, err := s.Load(second) + if err != nil { + t.Fatalf("Load second: %v", err) + } + if creds.AccessToken != "second" { + t.Errorf("second AccessToken = %q, want second", creds.AccessToken) + } +} + +func TestInvalidCredentialsFile(t *testing.T) { + s := testStore(t) + if err := os.MkdirAll(s.fallbackDir, 0700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(s.credentialsPath(), []byte("not-json"), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if _, err := s.Load("https://app.hey.com"); err == nil { + t.Fatal("Load succeeded with invalid JSON") + } + if err := s.Save("https://app.hey.com", &Credentials{AccessToken: "token"}); err == nil { + t.Fatal("Save overwrote invalid credentials file") + } + if err := s.Delete("https://app.hey.com"); err == nil { + t.Fatal("Delete overwrote invalid credentials file") + } +} + +func TestCredentialsPathReadFailure(t *testing.T) { + s := testStore(t) + if err := os.MkdirAll(s.credentialsPath(), 0700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + if _, err := s.Load("https://app.hey.com"); err == nil { + t.Fatal("Load succeeded when credentials path is a directory") + } + if err := s.Save("https://app.hey.com", &Credentials{AccessToken: "token"}); err == nil { + t.Fatal("Save succeeded when credentials path is a directory") + } +} + +func TestFallbackDirectoryCreationFailure(t *testing.T) { + parent := t.TempDir() + blockingFile := filepath.Join(parent, "not-a-directory") + if err := os.WriteFile(blockingFile, []byte("block"), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + t.Setenv("HEY_NO_KEYRING", "1") + s := NewStore(filepath.Join(blockingFile, "credentials")) + + err := s.Save("https://app.hey.com", &Credentials{AccessToken: "token"}) + if err == nil { + t.Fatal("Save succeeded beneath a regular file") + } +} + +func TestStoreCapturesNoKeyringPreference(t *testing.T) { + t.Setenv("HEY_NO_KEYRING", "1") + s := NewStore(t.TempDir()) + t.Setenv("HEY_NO_KEYRING", "") + + if s.UsingKeyring() { + t.Fatal("UsingKeyring = true after store was created with HEY_NO_KEYRING") + } +} + +func TestCredentialKeyIncludesOrigin(t *testing.T) { + origin := "https://app.hey.com" + if got := key(origin); got != "hey::"+origin { + t.Errorf("key(%q) = %q", origin, got) + } +} + +func TestFilePermissions(t *testing.T) { + t.Setenv("HEY_NO_KEYRING", "1") + s := NewStore(filepath.Join(t.TempDir(), "credentials")) origin := "https://app.hey.com" if err := s.Save(origin, &Credentials{AccessToken: "tok"}); err != nil { @@ -114,4 +401,21 @@ func TestFilePermissions(t *testing.T) { if perm != 0600 { t.Errorf("file permissions = %o, want 0600", perm) } + + dirInfo, err := os.Stat(s.fallbackDir) + if err != nil { + t.Fatalf("Stat fallback directory: %v", err) + } + if got := dirInfo.Mode().Perm(); got&0077 != 0 { + t.Errorf("directory permissions = %o, want no group/other access", got) + } +} + +func TestLoadErrorNamesMissingOrigin(t *testing.T) { + s := testStore(t) + origin := "https://missing.hey.com" + _, err := s.Load(origin) + if err == nil || !strings.Contains(err.Error(), origin) { + t.Fatalf("error = %v, want missing origin", err) + } } diff --git a/internal/cmd/auth_commands_test.go b/internal/cmd/auth_commands_test.go new file mode 100644 index 00000000..8c81df74 --- /dev/null +++ b/internal/cmd/auth_commands_test.go @@ -0,0 +1,255 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/basecamp/hey-cli/internal/auth" + "github.com/basecamp/hey-cli/internal/output" +) + +func runAuthCommand(t *testing.T, configHome, baseURL, envToken string, jsonOutput bool, args ...string) (string, output.Response, error) { + t.Helper() + t.Setenv("HEY_TOKEN", envToken) + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + t.Setenv("HOME", configHome) + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_STATE_HOME", configHome) + t.Setenv("XDG_CACHE_HOME", configHome) + + root := newRootCmd() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + commandArgs := []string{"--base-url", baseURL} + if jsonOutput { + commandArgs = append(commandArgs, "--json") + } + root.SetArgs(append(commandArgs, args...)) + err := root.Execute() + + var response output.Response + if jsonOutput && stdout.Len() > 0 { + if decodeErr := json.Unmarshal(stdout.Bytes(), &response); decodeErr != nil { + t.Fatalf("decode output %q: %v", stdout.String(), decodeErr) + } + } + return stdout.String(), response, err +} + +func TestAuthCookieLoginStatusAndLogout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + configHome := t.TempDir() + + _, login, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "login", "--cookie", "session-cookie") + if err != nil { + t.Fatalf("auth login: %v", err) + } + if login.Summary != "Logged in with session cookie" { + t.Errorf("login summary = %q", login.Summary) + } + method, ok := login.Data.(map[string]any) + if !ok || method["method"] != "cookie" { + t.Errorf("login data = %#v", login.Data) + } + + _, status, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "status") + if err != nil { + t.Fatalf("auth status: %v", err) + } + statusData, ok := status.Data.(map[string]any) + if !ok { + t.Fatalf("status data = %T", status.Data) + } + if statusData["authenticated"] != true || statusData["auth_type"] != "cookie" || statusData["storage"] != "file" { + t.Errorf("status data = %#v", statusData) + } + + _, logout, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "logout") + if err != nil { + t.Fatalf("auth logout: %v", err) + } + if logout.Summary != "Logged out" { + t.Errorf("logout summary = %q", logout.Summary) + } + + _, status, err = runAuthCommand(t, configHome, server.URL, "", true, "auth", "status") + if err != nil { + t.Fatalf("auth status after logout: %v", err) + } + statusData, ok = status.Data.(map[string]any) + if !ok || statusData["authenticated"] != false { + t.Errorf("status after logout = %#v", status.Data) + } +} + +func TestAuthTokenLoginAndStoredTokenOutput(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + configHome := t.TempDir() + + _, response, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "login", "--token", "stored-token") + if err != nil { + t.Fatalf("auth login: %v", err) + } + if response.Summary != "Logged in with token" { + t.Errorf("summary = %q", response.Summary) + } + + stdout, _, err := runAuthCommand(t, configHome, server.URL, "", false, "auth", "token", "--stored") + if err != nil { + t.Fatalf("auth token: %v", err) + } + if stdout != "stored-token" { + t.Errorf("token output = %q", stdout) + } +} + +func TestAuthStatusUsesEnvironmentTokenWithoutStorage(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + _, response, err := runAuthCommand(t, t.TempDir(), server.URL, "environment-token", true, "auth", "status") + if err != nil { + t.Fatalf("auth status: %v", err) + } + data, ok := response.Data.(map[string]any) + if !ok || data["authenticated"] != true || data["method"] != "env_var" { + t.Errorf("status = %#v", response.Data) + } +} + +func TestAuthRefreshCommand(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/oauth/tokens" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if got := r.Form.Get("refresh_token"); got != "old-refresh" { + t.Errorf("refresh_token = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}`) + })) + defer server.Close() + configHome := t.TempDir() + t.Setenv("HEY_NO_KEYRING", "1") + manager := auth.NewManager(server.URL, server.Client(), filepath.Join(configHome, "hey-cli")) + if err := manager.GetStore().Save(manager.CredentialKey(), &auth.Credentials{AccessToken: "old-access", RefreshToken: "old-refresh"}); err != nil { + t.Fatalf("seed credentials: %v", err) + } + + _, response, err := runAuthCommand(t, configHome, server.URL, "", true, "auth", "refresh") + if err != nil { + t.Fatalf("auth refresh: %v", err) + } + if response.Summary != "Token refreshed" { + t.Errorf("summary = %q", response.Summary) + } + creds, err := manager.GetStore().Load(manager.CredentialKey()) + if err != nil { + t.Fatalf("load refreshed credentials: %v", err) + } + if creds.AccessToken != "new-access" || creds.RefreshToken != "new-refresh" || creds.ExpiresAt <= time.Now().Unix() { + t.Errorf("refreshed credentials = %#v", creds) + } +} + +func TestAuthRefreshFailure(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + _, _, err := runAuthCommand(t, t.TempDir(), server.URL, "", true, "auth", "refresh") + if err == nil || !strings.Contains(err.Error(), "refresh failed: not authenticated") { + t.Fatalf("error = %v", err) + } +} + +func TestDoctorCommandReportsEnvironment(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + configHome := t.TempDir() + t.Setenv("SHELL", "/bin/zsh") + skillPath := filepath.Join(configHome, ".agents", "skills", "hey", "SKILL.md") + if err := os.MkdirAll(filepath.Dir(skillPath), 0755); err != nil { + t.Fatalf("create skill directory: %v", err) + } + if err := os.WriteFile(skillPath, []byte("# HEY"), 0600); err != nil { + t.Fatalf("write skill: %v", err) + } + + _, response, err := runAuthCommand(t, configHome, server.URL, "environment-token", true, "doctor") + if err != nil { + t.Fatalf("doctor: %v", err) + } + if response.Summary != "Doctor checks complete" { + t.Errorf("summary = %q", response.Summary) + } + checks, ok := response.Data.([]any) + if !ok { + t.Fatalf("checks = %T", response.Data) + } + byName := make(map[string]map[string]any) + for _, raw := range checks { + check, ok := raw.(map[string]any) + if !ok { + t.Fatalf("check = %T", raw) + } + name, _ := check["name"].(string) + byName[name] = check + } + if got := byName["Authentication"]["message"]; got != "Authenticated via HEY_TOKEN env var" { + t.Errorf("authentication message = %q", got) + } + if got := byName["Credentials"]["status"]; got != "warning" { + t.Errorf("credentials status = %q", got) + } + if got := byName["Shell"]["message"]; got != "/bin/zsh" { + t.Errorf("shell = %q", got) + } + if got := byName["Claude Skill"]["status"]; got != "ok" { + t.Errorf("Claude Skill status = %q", got) + } + if _, ok := byName["CLI Version"]; !ok { + t.Error("CLI Version check missing") + } + if _, ok := byName["Go Version"]; !ok { + t.Error("Go Version check missing") + } +} + +func TestDoctorCommandReportsMissingAuthentication(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + _, response, err := runAuthCommand(t, t.TempDir(), server.URL, "", true, "doctor") + if err != nil { + t.Fatalf("doctor: %v", err) + } + checks, ok := response.Data.([]any) + if !ok { + t.Fatalf("checks = %T", response.Data) + } + for _, raw := range checks { + check, _ := raw.(map[string]any) + if check["name"] == "Authentication" { + if check["status"] != "error" || !strings.Contains(check["message"].(string), "Not authenticated") { + t.Errorf("authentication check = %#v", check) + } + return + } + } + t.Fatal("Authentication check missing") +} diff --git a/internal/cmd/box_test.go b/internal/cmd/box_test.go index b72efc19..b5522b5c 100644 --- a/internal/cmd/box_test.go +++ b/internal/cmd/box_test.go @@ -3,7 +3,10 @@ package cmd import ( "context" "fmt" + "io" + "net/http" "strings" + "sync/atomic" "testing" "github.com/basecamp/hey-sdk/go/pkg/generated" @@ -82,6 +85,126 @@ func mockFetcher(pages []generated.BoxShowResponse) pageFetcher { } } +func TestBoxCommandNamedRoutes(t *testing.T) { + tests := []struct { + name string + box string + path string + }{ + {name: "Imbox", box: "imbox", path: "/imbox.json"}, + {name: "Feed", box: "the feed", path: "/feedbox.json"}, + {name: "Paper Trail", box: "paper trail", path: "/paper_trail.json"}, + {name: "Set Aside", box: "set aside", path: "/set_aside.json"}, + {name: "Reply Later", box: "reply later", path: "/reply_later.json"}, + {name: "Bubbled Up", box: "bubbled up", path: "/bubble_up.json"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requests atomic.Int32 + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodGet || r.URL.Path != tt.path { + t.Errorf("request = %s %s, want GET %s", r.Method, r.URL.Path, tt.path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"id":1,"kind":%q,"name":%q,"postings":[]}`, tt.box, tt.name) + }), "box", tt.box) + if err != nil { + t.Fatalf("execute box: %v", err) + } + if requests.Load() != 1 { + t.Errorf("requests = %d, want one named lookup", requests.Load()) + } + if response.Summary != "0 threads in "+tt.name { + t.Errorf("summary = %q", response.Summary) + } + }) + } +} + +func TestBoxCommandNumericIDAndLimit(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/boxes/17.json" { + t.Errorf("request = %s %s, want GET /boxes/17.json", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":17,"kind":"custom","name":"Receipts","next_history_url":"https://example.invalid/page-2","postings":[{"id":1,"summary":"First"},{"id":2,"summary":"Second"}]}`) + }), "box", "17", "--limit", "1") + if err != nil { + t.Fatalf("execute box: %v", err) + } + if response.Summary != "1 thread in Receipts" { + t.Errorf("summary = %q", response.Summary) + } + if response.Notice != "Showing 1 of 2 results. Use --all to see everything." { + t.Errorf("notice = %q", response.Notice) + } + data, ok := response.Data.(map[string]any) + if !ok { + t.Fatalf("data = %T", response.Data) + } + if postings, ok := data["postings"].([]any); !ok || len(postings) != 1 { + t.Errorf("postings = %#v, want one", data["postings"]) + } + if next, _ := data["next_history_url"].(string); next != "" { + t.Errorf("next_history_url = %q, want cleared after client truncation", next) + } +} + +func TestBoxCommandUnknownNameFallsBackToList(t *testing.T) { + var requests []string + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/boxes.json": + _, _ = io.WriteString(w, `[{"id":17,"kind":"receipts","name":"Receipts"}]`) + case "/boxes/17.json": + _, _ = io.WriteString(w, `{"id":17,"kind":"receipts","name":"Receipts","postings":[]}`) + default: + http.NotFound(w, r) + } + }), "box", "receipts") + if err != nil { + t.Fatalf("execute box: %v", err) + } + if got, want := fmt.Sprint(requests), "[GET /boxes.json GET /boxes/17.json]"; got != want { + t.Errorf("requests = %s, want %s", got, want) + } + if response.Summary != "0 threads in Receipts" { + t.Errorf("summary = %q", response.Summary) + } +} + +func TestBoxCommandRejectsCrossOriginPagination(t *testing.T) { + var requests atomic.Int32 + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":1,"kind":"imbox","name":"Imbox","next_history_url":"https://attacker.example/page-2","postings":[{"id":1}]}`) + }), "box", "imbox", "--all") + if err == nil || !strings.Contains(err.Error(), "pagination URL origin") { + t.Fatalf("error = %v, want cross-origin pagination rejection", err) + } + if requests.Load() != 1 { + t.Errorf("requests = %d, want no request to pagination origin", requests.Load()) + } +} + +func TestBoxCommandUnknownNameReturnsNotFound(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[{"id":17,"kind":"receipts","name":"Receipts"}]`) + }), "box", "newsletters") + if err == nil || !strings.Contains(err.Error(), `box "newsletters" not found`) { + t.Fatalf("error = %v, want box not found", err) + } +} + func TestBoxSummaryUsesThreadTerminology(t *testing.T) { tests := []struct { name string diff --git a/internal/cmd/calendar_commands_test.go b/internal/cmd/calendar_commands_test.go new file mode 100644 index 00000000..529a36fe --- /dev/null +++ b/internal/cmd/calendar_commands_test.go @@ -0,0 +1,353 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/basecamp/hey-cli/internal/output" +) + +func runJSONCommand(t *testing.T, handler http.Handler, args ...string) (output.Response, error) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + t.Setenv("HEY_TOKEN", "test-token") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_STATE_HOME", configHome) + t.Setenv("XDG_CACHE_HOME", configHome) + + root := newRootCmd() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(append([]string{"--json", "--base-url", server.URL}, args...)) + + err := root.Execute() + var response output.Response + if stdout.Len() > 0 { + if decodeErr := json.Unmarshal(stdout.Bytes(), &response); decodeErr != nil { + t.Fatalf("decode command output %q: %v", stdout.String(), decodeErr) + } + } + return response, err +} + +func TestCalendarsCommand(t *testing.T) { + var requests atomic.Int32 + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodGet || r.URL.Path != "/calendars.json" { + t.Errorf("request = %s %s, want GET /calendars.json", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":7,"name":"Personal","kind":"Calendar","owned":true,"personal":true}},{"calendar":{"id":9,"name":"Team","kind":"Calendar"}}]}`) + }), "calendars") + if err != nil { + t.Fatalf("execute calendars: %v", err) + } + if requests.Load() != 1 { + t.Errorf("requests = %d, want 1", requests.Load()) + } + if response.Summary != "2 calendars" { + t.Errorf("summary = %q, want 2 calendars", response.Summary) + } + items, ok := response.Data.([]any) + if !ok || len(items) != 2 { + t.Fatalf("data = %#v, want two calendars", response.Data) + } +} + +func TestCalendarsCommandAPIError(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "calendar unavailable", http.StatusBadRequest) + }), "calendars") + if err == nil || !strings.Contains(err.Error(), "400 Bad Request") { + t.Fatalf("error = %v, want HTTP failure", err) + } +} + +func TestRecordingsCommand(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/calendars/7/recordings.json" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("starts_on"); got != "2026-08-01" { + t.Errorf("starts_on = %q", got) + } + if got := r.URL.Query().Get("ends_on"); got != "2026-08-31" { + t.Errorf("ends_on = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":1,"title":"Planning","starts_at":"2026-08-02T09:00:00Z"},{"id":2,"title":"Review","starts_at":"2026-08-03T09:00:00Z"}],"Calendar::Todo":[{"id":3,"title":"Send notes"}]}`) + }), "recordings", "7", "--starts-on", "2026-08-01", "--ends-on", "2026-08-31", "--limit", "1") + if err != nil { + t.Fatalf("execute recordings: %v", err) + } + if response.Summary != "Recordings for calendar 7 (2026-08-01 to 2026-08-31)" { + t.Errorf("summary = %q", response.Summary) + } + if response.Notice != "Showing 2 of 3 results. Use --all to see everything." { + t.Errorf("notice = %q", response.Notice) + } + data, ok := response.Data.(map[string]any) + if !ok { + t.Fatalf("data = %T, want map", response.Data) + } + if events, ok := data["Calendar::Event"].([]any); !ok || len(events) != 1 { + t.Errorf("events = %#v, want one limited event", data["Calendar::Event"]) + } +} + +func TestRecordingsDefaultsEndDateFromStart(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("ends_on"); got != "2026-03-03" { + t.Errorf("ends_on = %q, want 30 days after 2026-02-01", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{}`) + }), "recordings", "7", "--starts-on", "2026-02-01") + if err != nil { + t.Fatalf("execute recordings: %v", err) + } + if response.Notice != "" { + t.Errorf("notice = %q, want empty", response.Notice) + } +} + +func TestRecordingsValidationMakesNoRequest(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "calendar ID", args: []string{"recordings", "not-an-id"}, want: "invalid calendar ID"}, + {name: "start date", args: []string{"recordings", "7", "--starts-on", "tomorrow"}, want: "invalid starts-on date"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requests atomic.Int32 + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "unexpected request", http.StatusInternalServerError) + }), tt.args...) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want substring %q", err, tt.want) + } + if requests.Load() != 0 { + t.Errorf("requests = %d, want 0", requests.Load()) + } + }) + } +} + +func personalCalendarHandler(t *testing.T, recordings string) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":7,"name":"Personal","personal":true}}]}`) + case "/calendars/7/recordings.json": + if r.URL.Query().Get("starts_on") == "" || r.URL.Query().Get("ends_on") == "" { + t.Error("personal recordings request omitted date window") + } + _, _ = io.WriteString(w, recordings) + default: + http.NotFound(w, r) + } + }) +} + +func TestTodoListCommand(t *testing.T) { + recordings := `{"Calendar::Todo":[{"id":1,"title":"First"},{"id":2,"title":"Second"}],"Calendar::TimeTrack":[{"id":3,"title":"Work"}]}` + response, err := runJSONCommand(t, personalCalendarHandler(t, recordings), "todo", "list", "--limit", "1") + if err != nil { + t.Fatalf("execute todo list: %v", err) + } + if response.Summary != "1 todos" { + t.Errorf("summary = %q", response.Summary) + } + if response.Notice != "Showing 1 of 2 results. Use --all to see everything." { + t.Errorf("notice = %q", response.Notice) + } + items, ok := response.Data.([]any) + if !ok || len(items) != 1 { + t.Fatalf("data = %#v, want one todo", response.Data) + } +} + +func TestTodoMutationCommands(t *testing.T) { + tests := []struct { + name string + args []string + method string + path string + status int + body string + wantSummary string + }{ + {name: "complete", args: []string{"todo", "complete", "42"}, method: http.MethodPost, path: "/calendar/todos/42/completions.json", status: http.StatusOK, body: `{"id":42,"title":"Ship release"}`, wantSummary: "Todo completed"}, + {name: "uncomplete", args: []string{"todo", "uncomplete", "42"}, method: http.MethodDelete, path: "/calendar/todos/42/completions.json", status: http.StatusOK, body: `{"id":42,"title":"Ship release"}`, wantSummary: "Todo marked incomplete"}, + {name: "delete", args: []string{"todo", "delete", "42"}, method: http.MethodDelete, path: "/calendar/todos/42.json", status: http.StatusNoContent, wantSummary: "Todo deleted"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != tt.method || r.URL.Path != tt.path { + t.Errorf("request = %s %s, want %s %s", r.Method, r.URL.Path, tt.method, tt.path) + } + if tt.body != "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(tt.status) + _, _ = io.WriteString(w, tt.body) + }), tt.args...) + if err != nil { + t.Fatalf("execute: %v", err) + } + if response.Summary != tt.wantSummary { + t.Errorf("summary = %q, want %q", response.Summary, tt.wantSummary) + } + }) + } +} + +func TestTodoMutationValidationMakesNoRequest(t *testing.T) { + for _, action := range []string{"complete", "uncomplete", "delete"} { + t.Run(action, func(t *testing.T) { + var requests atomic.Int32 + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + }), "todo", action, "invalid") + if err == nil || !strings.Contains(err.Error(), "invalid todo ID") { + t.Fatalf("error = %v, want invalid todo ID", err) + } + if requests.Load() != 0 { + t.Errorf("requests = %d, want 0", requests.Load()) + } + }) + } +} + +func TestTimetrackStartCommand(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/calendar/ongoing_time_track.json" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":42,"title":"Coverage work","starts_at":"2026-08-20T09:00:00Z"}`) + }), "timetrack", "start") + if err != nil { + t.Fatalf("execute timetrack start: %v", err) + } + if response.Summary != "Time tracking started" { + t.Errorf("summary = %q", response.Summary) + } + if len(response.Breadcrumbs) != 1 || response.Breadcrumbs[0].Action != "stop" { + t.Errorf("breadcrumbs = %#v", response.Breadcrumbs) + } +} + +func TestTimetrackCurrentCommand(t *testing.T) { + t.Run("active", func(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":42,"title":"Coverage work","starts_at":"2026-08-20T09:00:00Z"}`) + }), "timetrack", "current") + if err != nil { + t.Fatalf("execute current: %v", err) + } + if response.Summary != "Active time track #42" { + t.Errorf("summary = %q", response.Summary) + } + }) + + t.Run("inactive", func(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }), "timetrack", "current") + if err != nil { + t.Fatalf("execute current: %v", err) + } + if response.Summary != "No active time track" || response.Data != nil { + t.Errorf("response = %#v", response) + } + }) +} + +func TestTimetrackStopCommand(t *testing.T) { + var requests []string + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/calendar/ongoing_time_track.json": + _, _ = io.WriteString(w, `{"id":42,"title":"Coverage work"}`) + case r.Method == http.MethodPut && r.URL.Path == "/calendar/time_tracks/42.json": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode stop body: %v", err) + } + track, ok := body["calendar_time_track"].(map[string]any) + if !ok || track["ends_at"] == nil { + t.Errorf("stop body = %#v, want ends_at", body) + } + _, _ = io.WriteString(w, `{"id":42}`) + default: + http.NotFound(w, r) + } + }), "timetrack", "stop") + if err != nil { + t.Fatalf("execute stop: %v", err) + } + if response.Summary != "Time tracking stopped" { + t.Errorf("summary = %q", response.Summary) + } + wantRequests := []string{"GET /calendar/ongoing_time_track.json", "PUT /calendar/time_tracks/42.json"} + if fmt.Sprint(requests) != fmt.Sprint(wantRequests) { + t.Errorf("requests = %v, want %v", requests, wantRequests) + } +} + +func TestTimetrackStopWithoutActiveTrack(t *testing.T) { + var requests atomic.Int32 + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.NotFound(w, r) + }), "timetrack", "stop") + if err == nil || !strings.Contains(err.Error(), `time track "active" not found`) { + t.Fatalf("error = %v, want active track not found", err) + } + if requests.Load() != 1 { + t.Errorf("requests = %d, want only the lookup", requests.Load()) + } +} + +func TestTimetrackListCommand(t *testing.T) { + recordings := `{"Calendar::TimeTrack":[{"id":1,"title":"First"},{"id":2,"title":"Second"}],"Calendar::Todo":[{"id":3,"title":"Todo"}]}` + response, err := runJSONCommand(t, personalCalendarHandler(t, recordings), "timetrack", "list", "--limit", "1") + if err != nil { + t.Fatalf("execute timetrack list: %v", err) + } + if response.Summary != "1 time tracks" { + t.Errorf("summary = %q", response.Summary) + } + if response.Notice != "Showing 1 of 2 results. Use --all to see everything." { + t.Errorf("notice = %q", response.Notice) + } +} diff --git a/internal/cmd/drafts_test.go b/internal/cmd/drafts_test.go new file mode 100644 index 00000000..a9a3406c --- /dev/null +++ b/internal/cmd/drafts_test.go @@ -0,0 +1,132 @@ +package cmd + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func runStyledDraftsCommand(t *testing.T, handler http.Handler, args ...string) (string, error) { + t.Helper() + previousColorDisabled := colorDisabled + colorDisabled = false + t.Cleanup(func() { colorDisabled = previousColorDisabled }) + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + t.Setenv("HEY_TOKEN", "test-token") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_STATE_HOME", configHome) + t.Setenv("XDG_CACHE_HOME", configHome) + + root := newRootCmd() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(append([]string{"--styled", "--base-url", server.URL, "drafts"}, args...)) + + err := root.Execute() + return stdout.String(), err +} + +func TestDraftsCommandLimitsResults(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/entries/drafts.json" { + t.Errorf("request = %s %s, want GET /entries/drafts.json", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[ + {"id":101,"summary":"Agenda and decisions","subject":"Quarterly planning follow-up","updated_at":"2026-08-20T09:30:00Z"}, + {"id":102,"summary":"Travel details","subject":"Team retreat itinerary","updated_at":"2026-08-19T14:00:00Z"} + ]`) + }), "drafts", "--limit", "1") + if err != nil { + t.Fatalf("execute drafts: %v", err) + } + if response.Summary != "1 drafts" { + t.Errorf("summary = %q, want 1 drafts", response.Summary) + } + if response.Notice != "Showing 1 of 2 results. Use --all to see everything." { + t.Errorf("notice = %q", response.Notice) + } + items, ok := response.Data.([]any) + if !ok || len(items) != 1 { + t.Fatalf("data = %#v, want one draft", response.Data) + } + draft, ok := items[0].(map[string]any) + if !ok || draft["id"] != float64(101) { + t.Errorf("draft = %#v, want ID 101", items[0]) + } +} + +func TestDraftsCommandAllOverridesLimit(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[ + {"id":101,"subject":"Quarterly planning follow-up"}, + {"id":102,"subject":"Team retreat itinerary"} + ]`) + }), "drafts", "--limit", "1", "--all") + if err != nil { + t.Fatalf("execute drafts: %v", err) + } + items, ok := response.Data.([]any) + if !ok || len(items) != 2 { + t.Fatalf("data = %#v, want two drafts", response.Data) + } + if response.Summary != "2 drafts" || response.Notice != "" { + t.Errorf("response = %#v, want complete two-draft result", response) + } +} + +func TestDraftsCommandStyledTable(t *testing.T) { + const fullSummary = "Notes from the quarterly planning meeting including decisions and follow-up assignments" + stdout, err := runStyledDraftsCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[ + {"id":101,"summary":"`+fullSummary+`","subject":"Quarterly planning follow-up","updated_at":"2026-08-20T09:30:00Z"}, + {"id":102,"summary":"Travel details","subject":"Team retreat itinerary","updated_at":"2026-08-19T14:00:00Z"} + ]`) + }), "--limit", "1") + if err != nil { + t.Fatalf("execute styled drafts: %v", err) + } + for _, want := range []string{"ID", "Summary", "Subject", "Date", "101", "Quarterly planning follow-up", "2026-08-20", "...", "Showing 1 of 2 results"} { + if !strings.Contains(stdout, want) { + t.Errorf("output %q does not contain %q", stdout, want) + } + } + if strings.Contains(stdout, fullSummary) { + t.Errorf("output contains untruncated summary: %q", stdout) + } +} + +func TestDraftsCommandStyledEmpty(t *testing.T) { + stdout, err := runStyledDraftsCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + if err != nil { + t.Fatalf("execute styled drafts: %v", err) + } + if stdout != "No drafts.\n" { + t.Errorf("output = %q, want empty-state message", stdout) + } +} + +func TestDraftsCommandAPIError(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "drafts unavailable", http.StatusBadRequest) + }), "drafts") + if err == nil || !strings.Contains(err.Error(), "400 Bad Request") { + t.Fatalf("error = %v, want HTTP failure", err) + } +} diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh new file mode 100755 index 00000000..46abc959 --- /dev/null +++ b/scripts/check-coverage.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +profile=${1:-coverage.out} +floor=${2:-70.8} + +if [[ ! -s "$profile" ]]; then + echo "coverage profile not found: $profile" >&2 + exit 1 +fi + +read -r covered total < <( + awk ' + NR == 1 { next } + { + block = $1 + if (!(block in statements)) { + statements[block] = $2 + } + if ($3 > 0) { + hit[block] = 1 + } + } + END { + for (block in statements) { + total += statements[block] + if (block in hit) { + covered += statements[block] + } + } + print covered, total + } + ' "$profile" +) + +if [[ -z "$covered" || -z "$total" || "$total" -eq 0 ]]; then + echo "could not read statement coverage from $profile" >&2 + exit 1 +fi + +actual=$(awk -v covered="$covered" -v total="$total" 'BEGIN { printf "%.6f", 100 * covered / total }') +if ! awk -v actual="$actual" -v floor="$floor" 'BEGIN { exit !(actual + 0 >= floor + 0) }'; then + printf 'coverage %.3f%% (%d / %d statements) is below the %.3f%% floor\n' "$actual" "$covered" "$total" "$floor" >&2 + exit 1 +fi + +printf 'coverage %.3f%% (%d / %d statements) meets the %.3f%% floor\n' "$actual" "$covered" "$total" "$floor" diff --git a/scripts/coverage-summary.sh b/scripts/coverage-summary.sh new file mode 100755 index 00000000..d23437a5 --- /dev/null +++ b/scripts/coverage-summary.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +profile=${1:-coverage.out} +function_summary=${2:-coverage.func.txt} +package_summary=${3:-coverage.packages.txt} + +if [[ ! -s "$profile" ]]; then + echo "coverage profile not found: $profile" >&2 + exit 1 +fi + +GOWORK=off go tool cover -func="$profile" > "$function_summary" + +awk ' + NR == 1 { next } + { + block = $1 + file = block + sub(/:[0-9].*$/, "", file) + package = file + sub(/\/[^/]+$/, "", package) + sub(/^github.com\/basecamp\/hey-cli\//, "", package) + + if (!(block in statements)) { + statements[block] = $2 + packages[block] = package + totals[package] += $2 + } + if ($3 > 0 && !(block in hit)) { + hit[block] = 1 + covered[package] += statements[block] + } + } + END { + for (package in totals) { + percent = totals[package] == 0 ? 0 : 100 * covered[package] / totals[package] + printf "%s\t%d / %d\t%.1f%%\n", package, covered[package], totals[package], percent + } + } +' "$profile" | sort > "$package_summary" + +echo "Package coverage" +printf "PACKAGE\tSTATEMENTS\tCOVERAGE\n" +cat "$package_summary" + +echo +echo "Lowest-covered functions (up to 15)" +printf "FUNCTION\tCOVERAGE\n" +awk '$1 != "total:" { gsub(/%$/, "", $3); print $1 " " $2 "\t" $3 "%" }' "$function_summary" \ + | sort -t $'\t' -k2,2n \ + | awk 'NR <= 15' + +echo +awk '/^total:/ { print "Repository total\t" $NF }' "$function_summary"