diff --git a/internal/pipeline/live_dogfood.go b/internal/pipeline/live_dogfood.go index 5c00f2f09..621348929 100644 --- a/internal/pipeline/live_dogfood.go +++ b/internal/pipeline/live_dogfood.go @@ -1982,7 +1982,7 @@ func validLiveDogfoodJSONOutput(stdout string) bool { func liveDogfoodUnavailableForRunner(run liveDogfoodRun) bool { output := strings.ToLower(run.stdout + run.stderr) return strings.Contains(output, "http 403") || - liveDogfoodAuth401Output(output) || + liveDogfoodAuth401(run) || strings.Contains(output, "permission denied") || strings.Contains(output, "your credentials are valid but lack access") } @@ -2041,19 +2041,39 @@ func liveDogfoodRequiresTierSkipReason(annotations map[string]string, activeTier return fmt.Sprintf("blocked-fixture: requires auth tier %q", requiredTier) } +// liveDogfoodAuthExitCode is the typed exit code a printed CLI returns from +// authErr, so it is authoritative about the failure class regardless of how the +// vendor worded the 401 body. +const liveDogfoodAuthExitCode = 4 + func liveDogfoodAuth401(run liveDogfoodRun) bool { - return liveDogfoodAuth401Output(strings.ToLower(run.stdout + run.stderr)) + output := strings.ToLower(run.stdout + run.stderr) + if run.exitCode == liveDogfoodAuthExitCode && strings.Contains(output, "http 401") { + return true + } + return liveDogfoodAuth401Output(output) } func liveDogfoodAuth401Output(output string) bool { if !strings.Contains(output, "http 401") { return false } - return strings.Contains(output, "couldn't authenticate") || - strings.Contains(output, "could not authenticate") || - strings.Contains(output, "login required") || - strings.Contains(output, "request is missing required authentication credential") || - strings.Contains(output, "not authenticated") + return containsAnyOf(output, liveDogfoodAuth401Phrases) +} + +// Vendor 401 bodies are unstandardized; each entry is a lowercase substring +// observed in a real provider's unauthenticated response. +var liveDogfoodAuth401Phrases = []string{ + "couldn't authenticate", + "could not authenticate", + "login required", + "request is missing required authentication credential", + "not authenticated", + "invalid access token", + "invalid token", + "expired token", + "token expired", + "unauthorized", } func commandSupportsDryRun(help string) bool { diff --git a/internal/pipeline/live_dogfood_test.go b/internal/pipeline/live_dogfood_test.go index 33522bf12..6b42b2c93 100644 --- a/internal/pipeline/live_dogfood_test.go +++ b/internal/pipeline/live_dogfood_test.go @@ -2337,6 +2337,7 @@ func TestLiveDogfoodUnavailableForRunnerDoesNotHideNotFound(t *testing.T) { assert.True(t, liveDogfoodUnavailableForRunner(liveDogfoodRun{stderr: "HTTP 403 permission denied"})) assert.True(t, liveDogfoodUnavailableForRunner(liveDogfoodRun{stderr: "your credentials are valid but lack access"})) assert.True(t, liveDogfoodUnavailableForRunner(liveDogfoodRun{stderr: `HTTP 401: {"error":"Couldn't authenticate you"}`})) + assert.True(t, liveDogfoodUnavailableForRunner(liveDogfoodRun{stderr: `HTTP 401: {"code":124,"message":"Invalid access token."}`})) assert.False(t, liveDogfoodUnavailableForRunner(liveDogfoodRun{stderr: "HTTP 404 NotFound"})) } @@ -2362,11 +2363,26 @@ func TestLiveDogfoodAuth401OutputMatchesGooglePhrases(t *testing.T) { }`, want: true, }, + { + name: "invalid access token", + output: `Error: GET /users/me returned HTTP 401: {"code":124,"message":"Invalid access token."}`, + want: true, + }, + { + name: "bare unauthorized", + output: `Error: GET /users/me returned HTTP 401: {"error":"Unauthorized"}`, + want: true, + }, { name: "non 401", output: `Error: GET /widgets returned HTTP 404: {"error":"not found"}`, want: false, }, + { + name: "404 with auth-ish wording", + output: `Error: GET /widgets returned HTTP 404: {"error":"invalid access token"}`, + want: false, + }, } for _, tt := range cases { @@ -2378,6 +2394,58 @@ func TestLiveDogfoodAuth401OutputMatchesGooglePhrases(t *testing.T) { } } +func TestLiveDogfoodAuth401TypedExitCode(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + run liveDogfoodRun + want bool + }{ + { + name: "typed auth exit with unrecognized vendor wording", + run: liveDogfoodRun{ + exitCode: liveDogfoodAuthExitCode, + stderr: `Error: GET /users/me returned HTTP 401: {"code":9999,"message":"Nope."}`, + }, + want: true, + }, + { + name: "typed auth exit without a 401", + run: liveDogfoodRun{ + exitCode: liveDogfoodAuthExitCode, + stderr: `Error: no credentials configured`, + }, + want: false, + }, + { + name: "unrecognized wording on a generic failure exit", + run: liveDogfoodRun{ + exitCode: 1, + stderr: `Error: GET /users/me returned HTTP 401: {"code":9999,"message":"Nope."}`, + }, + want: false, + }, + { + name: "recognized wording on a generic failure exit", + run: liveDogfoodRun{ + exitCode: 1, + stderr: `Error: GET /users/me returned HTTP 401: {"code":124,"message":"Invalid access token."}`, + }, + want: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, liveDogfoodAuth401(tt.run)) + assert.Equal(t, tt.want, liveDogfoodUnavailableForRunner(tt.run)) + }) + } +} + func TestLiveDogfoodRequiredParamFixtureReason(t *testing.T) { t.Parallel()