diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 65549791..50c2403d 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -71,6 +71,8 @@ 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"}) } @@ -78,6 +80,8 @@ Use --token or --cookie for non-interactive login.`, 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"}) } @@ -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() @@ -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) }, } diff --git a/internal/cmd/sdk.go b/internal/cmd/sdk.go index 91e862cf..c064d2d4 100644 --- a/internal/cmd/sdk.go +++ b/internal/cmd/sdk.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "fmt" + "io" "log/slog" "net/http" "os" + "path/filepath" "strconv" "strings" "sync/atomic" @@ -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 + sdkCfg.CacheEnabled = true } var opts []hey.ClientOption @@ -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 { diff --git a/internal/cmd/sdk_cache_test.go b/internal/cmd/sdk_cache_test.go new file mode 100644 index 00000000..ef5255d1 --- /dev/null +++ b/internal/cmd/sdk_cache_test.go @@ -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") + } +} diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index f21fa311..28978c42 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -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()) }