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: 8 additions & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,17 @@ Use --token or --cookie for non-interactive login.`,
if err := authMgr.LoginWithToken(token); err != nil {
return apierr.ErrAuth(fmt.Sprintf("could not save token: %v", err))
}
// Replaced credentials orphan whatever the old ones cached.
clearHTTPCache(cmd.ErrOrStderr())
return writeMutation(cmd, "Logged in with token", map[string]string{"method": "token"})
}

if cookie != "" {
if err := authMgr.LoginWithCookie(cookie); err != nil {
return apierr.ErrAuth(fmt.Sprintf("could not save cookie: %v", err))
}
// Replaced credentials orphan whatever the old ones cached.
clearHTTPCache(cmd.ErrOrStderr())
return writeMutation(cmd, "Logged in with session cookie", map[string]string{"method": "cookie"})
}

Expand All @@ -87,6 +91,8 @@ Use --token or --cookie for non-interactive login.`,
if err := authMgr.Login(ctx, auth.LoginOptions{NoBrowser: noBrowser}); err != nil {
return apierr.ErrAuth(fmt.Sprintf("login failed: %v", err))
}
// Replaced credentials orphan whatever the old ones cached.
clearHTTPCache(cmd.ErrOrStderr())

if writer.IsStyled() {
w := cmd.OutOrStdout()
Expand Down Expand Up @@ -127,6 +133,8 @@ func buildLogoutCommand(path string) *cobra.Command {
if err := authMgr.Logout(); err != nil {
return apierr.ErrAuth(fmt.Sprintf("could not clear credentials: %v", err))
}
// Cached mail must not outlive the credentials that fetched it.
clearHTTPCache(cmd.ErrOrStderr())
return writeMutation(cmd, "Logged out", nil)
},
}
Expand Down
36 changes: 34 additions & 2 deletions internal/cmd/sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
Expand Down Expand Up @@ -76,8 +78,15 @@ var sdkStats *statsHooks
// initSDK creates the SDK client, bridging the CLI's auth and config.
func initSDK(authMgr *auth.Manager, baseURL string) {
sdkCfg := &hey.Config{
BaseURL: baseURL,
CacheEnabled: false,
BaseURL: baseURL,
}

// The SDK cache is a revalidation cache: every read still asks the server,
// conditionally, and only a 304 is answered from disk — so it can never serve
// stale mail. Logout clears it so cached mail does not outlive its credentials.
if dir := httpCacheDir(); dir != "" {
sdkCfg.CacheDir = dir
Comment thread
jeremy marked this conversation as resolved.
sdkCfg.CacheEnabled = true
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
}

var opts []hey.ClientOption
Expand All @@ -97,6 +106,29 @@ func initSDK(authMgr *auth.Manager, baseURL string) {
sdk = rootSDK
}

// httpCacheDir is where the SDK keeps its ETag response cache, or empty when no
// cache location can be resolved, which leaves caching off.
func httpCacheDir() string {
if dir := config.CacheDir(); dir != "" {
return filepath.Join(dir, "http")
}
return ""
}

// clearHTTPCache drops the SDK's response cache. It runs after a credential
// change has already happened, so a failure is reported rather than failing
// the command that carried it: the warning names the directory left to
// remove by hand.
func clearHTTPCache(errOut io.Writer) {
dir := httpCacheDir()
if dir == "" {
return
}
if err := hey.NewCache(dir).Clear(); err != nil {
fmt.Fprintf(errOut, "warning: could not clear cached responses in %s: %v\n", dir, err)
}
}

// newSDKClient builds another client sharing the CLI's configuration — auth,
// user agent, hooks, logging — plus any extra options. Valid after initSDK.
func newSDKClient(extra ...hey.ClientOption) *hey.Client {
Expand Down
82 changes: 82 additions & 0 deletions internal/cmd/sdk_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package cmd

import (
"os"
"path/filepath"
"testing"

"github.com/basecamp/hey-cli/internal/auth"
)

func TestInitSDKEnablesTheRevalidationCache(t *testing.T) {
cacheHome := t.TempDir()
t.Setenv("XDG_CACHE_HOME", cacheHome)

initSDK(auth.NewManager("https://app.hey.com", nil, t.TempDir()), "https://app.hey.com")

if !sdkClientCfg.CacheEnabled {
t.Error("expected the SDK cache enabled")
}
if want := filepath.Join(cacheHome, "hey-cli", "http"); sdkClientCfg.CacheDir != want {
t.Errorf("cache dir = %q, want %q", sdkClientCfg.CacheDir, want)
}
}

// A login over standing credentials replaces them, orphaning whatever the old
// ones cached: the replacement clears the cache without waiting for a logout.
func TestLoginReplacingCredentialsClearsTheHTTPCache(t *testing.T) {
configHome := t.TempDir()
responses := filepath.Join(configHome, "hey-cli", "http", "responses")
if err := os.MkdirAll(responses, 0o700); err != nil {
t.Fatal(err)
}
for name, content := range map[string]string{
filepath.Join(responses, "abc123.body"): `{"cached":"mail"}`,
filepath.Join(configHome, "hey-cli", "http", "etags.json"): `{"abc123":"\"v1\""}`,
} {
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}

if _, _, err := runAuthCommand(t, configHome, "https://app.hey.com", "", true, "auth", "login", "--cookie", "replacement-cookie"); err != nil {
t.Fatalf("auth login: %v", err)
}

if _, err := os.Stat(responses); !os.IsNotExist(err) {
t.Error("expected login to drop the previous credentials' cached responses")
}
if _, err := os.Stat(filepath.Join(configHome, "hey-cli", "http", "etags.json")); !os.IsNotExist(err) {
t.Error("expected login to drop the previous credentials' cached ETags")
}
}

func TestLogoutClearsTheHTTPCache(t *testing.T) {
configHome := t.TempDir()
responses := filepath.Join(configHome, "hey-cli", "http", "responses")
if err := os.MkdirAll(responses, 0o700); err != nil {
t.Fatal(err)
}
for name, content := range map[string]string{
filepath.Join(responses, "abc123.body"): `{"cached":"mail"}`,
filepath.Join(configHome, "hey-cli", "http", "etags.json"): `{"abc123":"\"v1\""}`,
} {
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}

if _, _, err := runAuthCommand(t, configHome, "https://app.hey.com", "", true, "auth", "login", "--cookie", "session-cookie"); err != nil {
t.Fatalf("auth login: %v", err)
}
if _, logout, err := runAuthCommand(t, configHome, "https://app.hey.com", "", true, "auth", "logout"); err != nil || logout.Summary != "Logged out" {
t.Fatalf("auth logout: %v (%q)", err, logout.Summary)
}

if _, err := os.Stat(responses); !os.IsNotExist(err) {
t.Error("expected logout to drop the cached responses")
}
if _, err := os.Stat(filepath.Join(configHome, "hey-cli", "http", "etags.json")); !os.IsNotExist(err) {
t.Error("expected logout to drop the cached ETags")
}
}
2 changes: 2 additions & 0 deletions internal/cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,8 @@ var loginInteractively = func(out io.Writer) error {
if err := authMgr.Login(ctx, auth.LoginOptions{Logger: logger}); err != nil {
return apierr.ErrAuth(fmt.Sprintf("login failed: %v", err))
}
// Replaced credentials orphan whatever the old ones cached.
clearHTTPCache(out)
return selectConfiguredAccount(context.Background())
}

Expand Down
Loading