[object Object]

← back to Cli Printing Press

fix(generator): scope caches by auth identity (#853)

1a8f68eeb91a433554c3ef781de4382ee3d82671 · 2026-05-09 14:41:39 -0700 · hnshah

Files touched

Diff

commit 1a8f68eeb91a433554c3ef781de4382ee3d82671
Author: hnshah <hnshah@gmail.com>
Date:   Sat May 9 14:41:39 2026 -0700

    fix(generator): scope caches by auth identity (#853)
---
 internal/generator/client_cache_scope_test.go      | 46 ++++++++++++++++++++++
 internal/generator/doctor_auth_status_test.go      | 37 +++++++++++++++++
 internal/generator/templates/client.go.tmpl        | 24 ++++++++---
 internal/generator/templates/doctor.go.tmpl        | 27 ++++++++++++-
 internal/generator/tier_routing_test.go            |  3 +-
 .../internal/client/client.go                      | 20 +++++++++-
 .../internal/cli/doctor.go                         | 10 +++++
 .../printing-press-golden/internal/cli/doctor.go   | 10 +++++
 .../internal/client/client.go                      | 20 +++++++++-
 .../tier-routing-golden/internal/cli/doctor.go     | 10 +++++
 .../tier-routing-golden/internal/client/client.go  | 22 +++++++++--
 11 files changed, 214 insertions(+), 15 deletions(-)

diff --git a/internal/generator/client_cache_scope_test.go b/internal/generator/client_cache_scope_test.go
new file mode 100644
index 00000000..817ad17d
--- /dev/null
+++ b/internal/generator/client_cache_scope_test.go
@@ -0,0 +1,46 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+	"github.com/stretchr/testify/require"
+)
+
+func TestClientCacheKeyScopesByBaseURLAndAuthIdentity(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("cache-scope")
+	apiSpec.Auth = spec.AuthConfig{
+		Type:    "bearer_token",
+		Header:  "Authorization",
+		EnvVars: []string{"CACHE_SCOPE_TOKEN"},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "cache-scope-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	clientSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	client := string(clientSrc)
+	body := clientCacheKeyBody(t, client)
+
+	require.Contains(t, body, `"|base_url=" + c.BaseURL`, "cache keys must isolate staging/prod or per-tenant base URLs")
+	require.Contains(t, body, `"|auth_source=" + c.Config.AuthSource`, "cache keys should distinguish env/config/profile auth sources")
+	require.Contains(t, body, `sha256.Sum256([]byte(c.Config.AuthHeader()))`, "cache keys should include an auth fingerprint without storing the raw token")
+	require.Contains(t, body, `sort.Strings(paramKeys)`, "cache keys should be deterministic for map params")
+}
+
+func clientCacheKeyBody(t *testing.T, content string) string {
+	t.Helper()
+	start := strings.Index(content, "func (c *Client) cacheKey(")
+	require.NotEqual(t, -1, start, "cacheKey function must be emitted")
+	body := content[start:]
+	if next := strings.Index(body[1:], "\nfunc "); next != -1 {
+		body = body[:next+1]
+	}
+	return body
+}
diff --git a/internal/generator/doctor_auth_status_test.go b/internal/generator/doctor_auth_status_test.go
new file mode 100644
index 00000000..b885a20a
--- /dev/null
+++ b/internal/generator/doctor_auth_status_test.go
@@ -0,0 +1,37 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDoctorReportsConfigAuthAsEnvVarsSatisfied(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("doctor-auth-status")
+	apiSpec.Auth = spec.AuthConfig{
+		Type:    "bearer_token",
+		Header:  "Authorization",
+		EnvVars: []string{"DOCTOR_AUTH_STATUS_TOKEN"},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "doctor-auth-status-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	doctorSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+	doctor := string(doctorSrc)
+
+	require.Contains(t, doctor, "authConfigured := false", "doctor should remember when cfg.AuthHeader() satisfied auth")
+	require.Contains(t, doctor, "credentials available from", "doctor env-var check should explain config-file credentials")
+	require.Contains(t, doctor, `report["env_vars"] = "OK " + strings.Join(authEnvInfo, "; ")`, "config credentials must not degrade env_vars to INFO/WARN")
+	require.NotContains(t, doctor, `if os.Getenv("DOCTOR_AUTH_STATUS_TOKEN") != "" {
+				authEnvSet++
+			}
+
+			if authEnvSet == 0 {`, "legacy EnvVars branch must not report zero env vars when config auth is already valid")
+}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 4099c4bf..79d40acd 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -20,9 +20,7 @@ import (
 {{- end}}
 	"os"
 	"path/filepath"
-{{- if ne .ClientPattern "proxy-envelope"}}
 	"sort"
-{{- end}}
 	"strings"
 {{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
 	"sync"
@@ -375,11 +373,27 @@ func (c *Client) ProbeGet(path string) (int, error) {
 
 func (c *Client) cacheKey(path string, params map[string]string) string {
 	key := path
+	key += "|base_url=" + c.BaseURL
 {{- if .HasTierRouting}}
-	key += "|tier=" + c.requestTier + "|base_url=" + c.baseURLForRequest()
+	key += "|tier=" + c.requestTier + "|tier_base_url=" + c.baseURLForRequest()
 {{- end}}
-	for k, v := range params {
-		key += k + "=" + v
+	if c.Config != nil {
+		key += "|auth_source=" + c.Config.AuthSource
+		if authHeader := c.Config.AuthHeader(); authHeader != "" {
+			authHash := sha256.Sum256([]byte(c.Config.AuthHeader()))
+			key += "|auth=" + hex.EncodeToString(authHash[:8])
+		}
+		if c.Config.Path != "" {
+			key += "|config_path=" + c.Config.Path
+		}
+	}
+	paramKeys := make([]string, 0, len(params))
+	for k := range params {
+		paramKeys = append(paramKeys, k)
+	}
+	sort.Strings(paramKeys)
+	for _, k := range paramKeys {
+		key += k + "=" + params[k]
 	}
 {{- if .EndpointTemplateVars}}
 	// Include resolved template-var values in the cache identity so two
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 47ac975d..99a88721 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -134,7 +134,9 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			// Check auth
 {{- if or (eq .Auth.Type "") (eq .Auth.Type "none")}}
 			report["auth"] = "not required"
-{{- else if .Auth.Inferred}}
+{{- else}}
+			authConfigured := false
+{{- if .Auth.Inferred}}
 			// Auth was inferred from spec description — warn the user to verify
 			if cfg != nil {
 				header := cfg.AuthHeader()
@@ -144,6 +146,7 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					report["auth_hint"] = "export {{.Name}}=<your-key>"
 {{- end}}
 				} else {
+					authConfigured = true
 					report["auth"] = "inferred (configured — verify header and env var are correct)"
 					report["auth_source"] = cfg.AuthSource
 				}
@@ -157,6 +160,7 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					report["auth"] = "not configured"
 					report["auth_hint"] = "{{.Name}}-pp-cli auth login --chrome"
 				} else {
+					authConfigured = true
 					report["auth"] = "configured (browser session)"
 					report["auth_source"] = cfg.AuthSource
 					report["auth_domain"] = "{{.Auth.CookieDomain}}"
@@ -203,10 +207,15 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					report["auth_key_url"] = "{{.Auth.KeyURL}}"
 {{- end}}
 				} else {
+					authConfigured = true
 					report["auth"] = "configured"
 					report["auth_source"] = cfg.AuthSource
 				}
 			}
+{{- end}}
+{{- if not (or .Auth.EnvVarSpecs .Auth.EnvVars)}}
+			_ = authConfigured
+{{- end}}
 {{- end}}
 
 			// Check auth environment variables
@@ -228,6 +237,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 {{- if .Required}}
 			if os.Getenv("{{.Name}}") != "" {
 				authEnvSet = append(authEnvSet, "{{.Name}}")
+			} else if authConfigured {
+				authSource, _ := report["auth_source"].(string)
+				if authSource == "" {
+					authSource = "config"
+				}
+				authEnvInfo = append(authEnvInfo, "credentials available from "+authSource)
 			} else {
 				authEnvRequiredMissing = append(authEnvRequiredMissing, "{{.Name}}")
 			}
@@ -263,6 +278,8 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				report["env_vars"] = "ERROR missing required: " + strings.Join(authEnvRequiredMissing, ", ")
 			case len(authEnvOptionalNames) > 1 && !authEnvOptionalSatisfied:
 				report["env_vars"] = "INFO set one of: " + strings.Join(authEnvOptionalNames, " or ")
+			case len(authEnvInfo) > 0 && authConfigured:
+				report["env_vars"] = "OK " + strings.Join(authEnvInfo, "; ")
 			case len(authEnvInfo) > 0:
 				report["env_vars"] = "INFO " + strings.Join(authEnvInfo, "; ")
 			default:
@@ -277,7 +294,13 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				authEnvSet++
 			}
 {{- end}}
-			if authEnvSet == 0 {
+			if authEnvSet == 0 && authConfigured {
+				authSource, _ := report["auth_source"].(string)
+				if authSource == "" {
+					authSource = "config"
+				}
+				report["env_vars"] = "OK credentials available from " + authSource
+			} else if authEnvSet == 0 {
 				report["env_vars"] = fmt.Sprintf("none set (checked %d)", authEnvChecked)
 			} else {
 				report["env_vars"] = fmt.Sprintf("%d/%d set", authEnvSet, authEnvChecked)
diff --git a/internal/generator/tier_routing_test.go b/internal/generator/tier_routing_test.go
index dae657a6..4d633f81 100644
--- a/internal/generator/tier_routing_test.go
+++ b/internal/generator/tier_routing_test.go
@@ -93,7 +93,8 @@ func TestTierRoutingEmitsTierAwareClientAndCommands(t *testing.T) {
 	require.Contains(t, clientSrc, `os.Getenv("TIERED_PAID_KEY")`)
 	require.Contains(t, clientSrc, `"access_token": tierValue0`)
 	require.Contains(t, clientSrc, `q.Set(authInfo.Name, authHeader)`)
-	require.Contains(t, clientSrc, `key += "|tier=" + c.requestTier + "|base_url=" + c.baseURLForRequest()`)
+	require.Contains(t, clientSrc, `key += "|base_url=" + c.BaseURL`)
+	require.Contains(t, clientSrc, `key += "|tier=" + c.requestTier + "|tier_base_url=" + c.baseURLForRequest()`)
 
 	freeCmd := readGeneratedFile(t, outputDir, "internal", "cli", "items_list.go")
 	require.Contains(t, freeCmd, `c = c.WithTier("free")`)
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
index 901bd1fc..497d4973 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
@@ -99,8 +99,24 @@ func (c *Client) ProbeGet(path string) (int, error) {
 
 func (c *Client) cacheKey(path string, params map[string]string) string {
 	key := path
-	for k, v := range params {
-		key += k + "=" + v
+	key += "|base_url=" + c.BaseURL
+	if c.Config != nil {
+		key += "|auth_source=" + c.Config.AuthSource
+		if authHeader := c.Config.AuthHeader(); authHeader != "" {
+			authHash := sha256.Sum256([]byte(c.Config.AuthHeader()))
+			key += "|auth=" + hex.EncodeToString(authHash[:8])
+		}
+		if c.Config.Path != "" {
+			key += "|config_path=" + c.Config.Path
+		}
+	}
+	paramKeys := make([]string, 0, len(params))
+	for k := range params {
+		paramKeys = append(paramKeys, k)
+	}
+	sort.Strings(paramKeys)
+	for _, k := range paramKeys {
+		key += k + "=" + params[k]
 	}
 	h := sha256.Sum256([]byte(key))
 	return hex.EncodeToString(h[:8])
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go
index 6fb0f8e5..6e68e94d 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/doctor.go
@@ -85,12 +85,14 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			}
 
 			// Check auth
+			authConfigured := false
 			if cfg != nil {
 				header := cfg.AuthHeader()
 				if header == "" {
 					report["auth"] = "not configured"
 					report["auth_hint"] = "export RICH_AUTH_API_KEY=<your-key>"
 				} else {
+					authConfigured = true
 					report["auth"] = "configured"
 					report["auth_source"] = cfg.AuthSource
 				}
@@ -109,6 +111,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			}
 			if os.Getenv("RICH_AUTH_API_KEY") != "" {
 				authEnvSet = append(authEnvSet, "RICH_AUTH_API_KEY")
+			} else if authConfigured {
+				authSource, _ := report["auth_source"].(string)
+				if authSource == "" {
+					authSource = "config"
+				}
+				authEnvInfo = append(authEnvInfo, "credentials available from "+authSource)
 			} else {
 				authEnvRequiredMissing = append(authEnvRequiredMissing, "RICH_AUTH_API_KEY")
 			}
@@ -165,6 +173,8 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				report["env_vars"] = "ERROR missing required: " + strings.Join(authEnvRequiredMissing, ", ")
 			case len(authEnvOptionalNames) > 1 && !authEnvOptionalSatisfied:
 				report["env_vars"] = "INFO set one of: " + strings.Join(authEnvOptionalNames, " or ")
+			case len(authEnvInfo) > 0 && authConfigured:
+				report["env_vars"] = "OK " + strings.Join(authEnvInfo, "; ")
 			case len(authEnvInfo) > 0:
 				report["env_vars"] = "INFO " + strings.Join(authEnvInfo, "; ")
 			default:
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
index 09b86a28..6172b3fd 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
@@ -85,12 +85,14 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			}
 
 			// Check auth
+			authConfigured := false
 			if cfg != nil {
 				header := cfg.AuthHeader()
 				if header == "" {
 					report["auth"] = "not configured"
 					report["auth_hint"] = "export PRINTING_PRESS_GOLDEN_API_KEY=<your-key>"
 				} else {
+					authConfigured = true
 					report["auth"] = "configured"
 					report["auth_source"] = cfg.AuthSource
 				}
@@ -105,6 +107,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			authEnvOptionalSatisfied := false
 			if os.Getenv("PRINTING_PRESS_GOLDEN_API_KEY") != "" {
 				authEnvSet = append(authEnvSet, "PRINTING_PRESS_GOLDEN_API_KEY")
+			} else if authConfigured {
+				authSource, _ := report["auth_source"].(string)
+				if authSource == "" {
+					authSource = "config"
+				}
+				authEnvInfo = append(authEnvInfo, "credentials available from "+authSource)
 			} else {
 				authEnvRequiredMissing = append(authEnvRequiredMissing, "PRINTING_PRESS_GOLDEN_API_KEY")
 			}
@@ -113,6 +121,8 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				report["env_vars"] = "ERROR missing required: " + strings.Join(authEnvRequiredMissing, ", ")
 			case len(authEnvOptionalNames) > 1 && !authEnvOptionalSatisfied:
 				report["env_vars"] = "INFO set one of: " + strings.Join(authEnvOptionalNames, " or ")
+			case len(authEnvInfo) > 0 && authConfigured:
+				report["env_vars"] = "OK " + strings.Join(authEnvInfo, "; ")
 			case len(authEnvInfo) > 0:
 				report["env_vars"] = "INFO " + strings.Join(authEnvInfo, "; ")
 			default:
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
index 80eff7fb..331598ff 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
@@ -93,8 +93,24 @@ func (c *Client) ProbeGet(path string) (int, error) {
 
 func (c *Client) cacheKey(path string, params map[string]string) string {
 	key := path
-	for k, v := range params {
-		key += k + "=" + v
+	key += "|base_url=" + c.BaseURL
+	if c.Config != nil {
+		key += "|auth_source=" + c.Config.AuthSource
+		if authHeader := c.Config.AuthHeader(); authHeader != "" {
+			authHash := sha256.Sum256([]byte(c.Config.AuthHeader()))
+			key += "|auth=" + hex.EncodeToString(authHash[:8])
+		}
+		if c.Config.Path != "" {
+			key += "|config_path=" + c.Config.Path
+		}
+	}
+	paramKeys := make([]string, 0, len(params))
+	for k := range params {
+		paramKeys = append(paramKeys, k)
+	}
+	sort.Strings(paramKeys)
+	for _, k := range paramKeys {
+		key += k + "=" + params[k]
 	}
 	h := sha256.Sum256([]byte(key))
 	return hex.EncodeToString(h[:8])
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
index 65c4ec8c..d57149cd 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
@@ -85,12 +85,14 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			}
 
 			// Check auth
+			authConfigured := false
 			if cfg != nil {
 				header := cfg.AuthHeader()
 				if header == "" {
 					report["auth"] = "not configured"
 					report["auth_hint"] = "export TIER_GLOBAL_TOKEN=<your-key>"
 				} else {
+					authConfigured = true
 					report["auth"] = "configured"
 					report["auth_source"] = cfg.AuthSource
 				}
@@ -105,6 +107,12 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			authEnvOptionalSatisfied := false
 			if os.Getenv("TIER_GLOBAL_TOKEN") != "" {
 				authEnvSet = append(authEnvSet, "TIER_GLOBAL_TOKEN")
+			} else if authConfigured {
+				authSource, _ := report["auth_source"].(string)
+				if authSource == "" {
+					authSource = "config"
+				}
+				authEnvInfo = append(authEnvInfo, "credentials available from "+authSource)
 			} else {
 				authEnvRequiredMissing = append(authEnvRequiredMissing, "TIER_GLOBAL_TOKEN")
 			}
@@ -113,6 +121,8 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				report["env_vars"] = "ERROR missing required: " + strings.Join(authEnvRequiredMissing, ", ")
 			case len(authEnvOptionalNames) > 1 && !authEnvOptionalSatisfied:
 				report["env_vars"] = "INFO set one of: " + strings.Join(authEnvOptionalNames, " or ")
+			case len(authEnvInfo) > 0 && authConfigured:
+				report["env_vars"] = "OK " + strings.Join(authEnvInfo, "; ")
 			case len(authEnvInfo) > 0:
 				report["env_vars"] = "INFO " + strings.Join(authEnvInfo, "; ")
 			default:
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
index 09787659..d271cd0b 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
@@ -188,9 +188,25 @@ func (c *Client) ProbeGet(path string) (int, error) {
 
 func (c *Client) cacheKey(path string, params map[string]string) string {
 	key := path
-	key += "|tier=" + c.requestTier + "|base_url=" + c.baseURLForRequest()
-	for k, v := range params {
-		key += k + "=" + v
+	key += "|base_url=" + c.BaseURL
+	key += "|tier=" + c.requestTier + "|tier_base_url=" + c.baseURLForRequest()
+	if c.Config != nil {
+		key += "|auth_source=" + c.Config.AuthSource
+		if authHeader := c.Config.AuthHeader(); authHeader != "" {
+			authHash := sha256.Sum256([]byte(c.Config.AuthHeader()))
+			key += "|auth=" + hex.EncodeToString(authHash[:8])
+		}
+		if c.Config.Path != "" {
+			key += "|config_path=" + c.Config.Path
+		}
+	}
+	paramKeys := make([]string, 0, len(params))
+	for k := range params {
+		paramKeys = append(paramKeys, k)
+	}
+	sort.Strings(paramKeys)
+	for _, k := range paramKeys {
+		key += k + "=" + params[k]
 	}
 	h := sha256.Sum256([]byte(key))
 	return hex.EncodeToString(h[:8])

← 9f2a4efe fix(cli): default Surf browser transport to h2 (#850)  ·  back to Cli Printing Press  ·  fix(skills): correct update preflight compatibility (#854) 4b29a634 →