Skip to content

Commit 52cd401

Browse files
Ensure errors don't cause silent failure, make authN failure messages friendlier (#89)
* Ensure errors don't cause silent failure, make authN failure messages friendlier * Make CommandHarness accessible in non-integration tests
1 parent a54e910 commit 52cd401

8 files changed

Lines changed: 444 additions & 140 deletions

File tree

‎temporalcloudcli/cloud.go‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,27 +74,42 @@ func (b *CloudOptionsBuilder) Build(ctx context.Context) (*CloudOptions, error)
7474
return cloudOpts, nil
7575
}
7676

77+
type (
78+
errSlot struct{ err error }
79+
errSlotKey struct{}
80+
)
81+
82+
// errorContext will add err to the errSlot on ctx if it has one before returning v and err
83+
func errorContext[T any](ctx context.Context, v T, err error) (T, error) {
84+
if ctx != nil {
85+
if slot, ok := ctx.Value(errSlotKey{}).(*errSlot); ok {
86+
slot.err = err
87+
}
88+
}
89+
return v, err
90+
}
91+
7792
func (c *CloudOptions) GetAPIKey(ctx context.Context) (string, error) {
7893
loadClientOauthRes, err := cliext.LoadClientOAuth(cliext.LoadClientOAuthOptions{
7994
ConfigFilePath: c.ConfigFile,
8095
ProfileName: c.Profile,
8196
EnvLookup: envconfig.EnvLookupOS,
8297
})
8398
if err != nil {
84-
return "", fmt.Errorf("failed to load login configuration: %w, please run `temporal cloud login --reset`", err)
99+
return errorContext(ctx, "", NewFriendlyError("failed to load login configuration, please run `temporal cloud login --reset`", err))
85100
}
86101

87102
// check if we have had a valid token in the past
88103
if loadClientOauthRes.OAuth == nil || loadClientOauthRes.OAuth.ClientConfig == nil {
89-
return "", fmt.Errorf("no login session found, please run `temporal cloud login`")
104+
return errorContext(ctx, "", NewFriendlyError("no login session found, please run `temporal cloud login`", nil))
90105
}
91106

92107
token, refreshed, err := GetToken(ctx, loadClientOauthRes.OAuth.ClientConfig, loadClientOauthRes.OAuth.Token)
93108
if err != nil {
94109
if errors.Is(err, ErrLoginRequired) {
95-
return "", fmt.Errorf("login session expired, please run `temporal cloud login`: %w", err)
110+
return errorContext(ctx, "", NewFriendlyError("login session expired, please run `temporal cloud login`", err))
96111
}
97-
return "", fmt.Errorf("failed to get access token: %w", err)
112+
return errorContext(ctx, "", NewFriendlyErrorf("failed to get access token: %v", err))
98113
}
99114
if refreshed {
100115
loadClientOauthRes.OAuth.Token = token
@@ -104,7 +119,7 @@ func (c *CloudOptions) GetAPIKey(ctx context.Context) (string, error) {
104119
ProfileName: c.Profile,
105120
EnvLookup: envconfig.EnvLookupOS,
106121
}); err != nil {
107-
return "", fmt.Errorf("failed to write config file: %w", err)
122+
return errorContext(ctx, "", NewFriendlyErrorf("failed to write config file: %v", err))
108123
}
109124
}
110125
return token.AccessToken, nil

‎temporalcloudcli/commands.go‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"context"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"io"
910
"log/slog"
@@ -185,6 +186,7 @@ func (cctx *CommandContext) BuildCloudClient(clientOpts ClientOptions) (*cloudcl
185186

186187
opts.GRPCDialOptions = []grpc.DialOption{
187188
grpc.WithChainUnaryInterceptor(
189+
grabOriginalErrorInterceptor,
188190
ClearDeprecatedFieldsInterceptor,
189191
),
190192
}
@@ -196,6 +198,28 @@ func (cctx *CommandContext) BuildCloudClient(clientOpts ClientOptions) (*cloudcl
196198
return cloudClient, nil
197199
}
198200

201+
// grabOriginalErrorInterceptor is a gRPC unary client interceptor that adds
202+
// a "slot" for error storage to the context passed to invoker. If an error occurs
203+
// and a downstream function has stored an error in errSlot, the two will be grafted
204+
// together. This allows for upstream error handling to perform checks with errors.Is
205+
// or errors.As, since grpc itself doesn't wrap errors.
206+
func grabOriginalErrorInterceptor(
207+
ctx context.Context,
208+
method string,
209+
req, reply any,
210+
cc *grpc.ClientConn,
211+
invoker grpc.UnaryInvoker,
212+
opts ...grpc.CallOption,
213+
) error {
214+
slot := &errSlot{}
215+
ctx = context.WithValue(ctx, errSlotKey{}, slot)
216+
err := invoker(ctx, method, req, reply, cc, opts...)
217+
if err != nil {
218+
return GraftErrors(err, slot.err)
219+
}
220+
return nil
221+
}
222+
199223
// clearDeprecatedFieldsInterceptor is a gRPC unary client interceptor that strips
200224
// deprecated fields from every response. Any proto field whose name ends with
201225
// "_deprecated" or that is marked with [deprecated = true] is cleared; nested
@@ -244,10 +268,23 @@ func (c *CommandContext) preprocessOptions() error {
244268
if c.Err() != nil {
245269
err = fmt.Errorf("program interrupted")
246270
}
271+
247272
if c.Logger != nil {
248273
c.Logger.Error(err.Error())
249-
} else {
250-
fmt.Fprintln(os.Stderr, err)
274+
}
275+
276+
// FriendlyErrors are intended for presentation directly to the user without requiring the full error context
277+
// If we got one, we can print it and exit
278+
if friendlyError, ok := errors.AsType[FriendlyError](err); ok {
279+
fmt.Fprintln(c.Options.Stderr, friendlyError.FriendlyError())
280+
os.Exit(1)
281+
}
282+
283+
// We weren't able to obtain a simple message to print, so we make sure the full error context gets sent
284+
// *somewhere*. If we don't have a logger or it's not sending messages anywhere (the default noop logger),
285+
// then we print the full error to stderr to ensure we don't just fail silently.
286+
if c.Logger == nil || !c.Logger.Enabled(nil, slog.LevelError) {
287+
fmt.Fprintln(c.Options.Stderr, err)
251288
}
252289
os.Exit(1)
253290
}

‎temporalcloudcli/commands.whoami.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import (
99
// run calls GetCurrentIdentity and prints the authenticated principal (User or
1010
// ServiceAccount) along with the associated API key, if any.
1111
func (c *CloudWhoamiCommand) run(cctx *CommandContext, _ []string) error {
12-
cloudClient, err := cctx.BuildCloudClient(c.ClientOptions)
12+
cloudClient, err := cctx.GetCloudClient(c.ClientOptions)
1313
if err != nil {
1414
return err
1515
}
1616

17-
res, err := cloudClient.CloudService().GetCurrentIdentity(cctx.Context, &cloudservice.GetCurrentIdentityRequest{})
17+
res, err := cloudClient.GetCurrentIdentity(cctx.Context, &cloudservice.GetCurrentIdentityRequest{})
1818
if err != nil {
1919
return err
2020
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package temporalcloudcli_test
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/stretchr/testify/mock"
8+
cloudmock "github.com/temporalio/cloud-cli/internal/cloudservice/mock"
9+
"github.com/temporalio/cloud-cli/temporalcloudcli"
10+
cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1"
11+
)
12+
13+
func TestWhoami(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
cmd temporalcloudcli.CloudWhoamiCommand
17+
cloudClientExpectations func(*cloudmock.MockCloudServiceClient)
18+
expectedErr string
19+
}{
20+
{
21+
name: "WhoamiSuccess",
22+
cmd: temporalcloudcli.CloudWhoamiCommand{},
23+
cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) {
24+
c.EXPECT().
25+
GetCurrentIdentity(mock.Anything, &cloudservice.GetCurrentIdentityRequest{}, mock.Anything).
26+
Return(&cloudservice.GetCurrentIdentityResponse{}, nil)
27+
},
28+
},
29+
{
30+
name: "WhoamiNotLoggedIn",
31+
cmd: temporalcloudcli.CloudWhoamiCommand{},
32+
cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) {
33+
c.EXPECT().
34+
GetCurrentIdentity(mock.Anything, &cloudservice.GetCurrentIdentityRequest{}, mock.Anything).
35+
Return(nil, fmt.Errorf("no login session found, please run `temporal cloud login`"))
36+
},
37+
expectedErr: "no login session found, please run `temporal cloud login`",
38+
},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
temporalcloudcli.TestCommand(t, &tt.cmd, temporalcloudcli.TestCommandOptions{
44+
CloudClientExpectations: tt.cloudClientExpectations,
45+
ExpectedError: tt.expectedErr,
46+
})
47+
})
48+
}
49+
}

‎temporalcloudcli/commands_test.go‎

Lines changed: 0 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,12 @@
44
package temporalcloudcli_test
55

66
import (
7-
"bytes"
87
"context"
98
"errors"
10-
"fmt"
119
"io"
1210
"math/rand"
1311
"os"
14-
"regexp"
15-
"strings"
1612
"testing"
17-
"time"
1813

1914
"github.com/stretchr/testify/assert"
2015
"github.com/stretchr/testify/require"
@@ -27,132 +22,6 @@ import (
2722
"github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer"
2823
)
2924

30-
type CommandHarness struct {
31-
*require.Assertions
32-
t *testing.T
33-
Options temporalcloudcli.CommandOptions
34-
// Defaults to a context closed on close or test complete
35-
Context context.Context
36-
// Can be used to cancel context given to commands (simulating interrupt)
37-
CancelContext context.CancelFunc
38-
Stdin bytes.Buffer
39-
}
40-
41-
func NewCommandHarness(t *testing.T) *CommandHarness {
42-
h := &CommandHarness{Assertions: require.New(t), t: t}
43-
h.Context, h.CancelContext = context.WithCancel(context.Background())
44-
t.Cleanup(h.Close)
45-
return h
46-
}
47-
48-
// Reentrant, called after test by default, cancels context
49-
func (h *CommandHarness) Close() {
50-
// Cancel context
51-
if h.CancelContext != nil {
52-
h.CancelContext()
53-
}
54-
}
55-
56-
// Pieces must appear in order on the line and not overlap
57-
func (h *CommandHarness) ContainsOnSameLine(text string, pieces ...string) {
58-
h.NoError(AssertContainsOnSameLine(text, pieces...))
59-
}
60-
61-
func AssertContainsOnSameLine(text string, pieces ...string) error {
62-
// Build regex pattern based on pieces
63-
pattern := ""
64-
for _, piece := range pieces {
65-
if pattern != "" {
66-
pattern += ".*"
67-
}
68-
pattern += regexp.QuoteMeta(piece)
69-
}
70-
regex, err := regexp.Compile(pattern)
71-
if err != nil {
72-
return err
73-
}
74-
// Split into lines, then check each piece is present
75-
lines := strings.Split(text, "\n")
76-
for _, line := range lines {
77-
if regex.MatchString(line) {
78-
return nil
79-
}
80-
}
81-
return fmt.Errorf("pieces not found in order on any line together")
82-
}
83-
84-
func TestAssertContainsOnSameLine(t *testing.T) {
85-
require.Error(t, AssertContainsOnSameLine("a b c", "b", "a"))
86-
require.Error(t, AssertContainsOnSameLine("a\nb c", "a", "b"))
87-
require.NoError(t, AssertContainsOnSameLine("aba", "b", "a"))
88-
require.NoError(t, AssertContainsOnSameLine("a b a", "b", "a"))
89-
require.NoError(t, AssertContainsOnSameLine("axb", "a", "b"))
90-
require.NoError(t, AssertContainsOnSameLine("a a", "a", "a"))
91-
}
92-
93-
func (h *CommandHarness) Eventually(
94-
condition func() bool,
95-
waitFor time.Duration,
96-
tick time.Duration,
97-
msgAndArgs ...interface{},
98-
) {
99-
h.t.Helper()
100-
// We cannot use require.Eventually because it was poorly developed to run the
101-
// condition function in a goroutine which means it can run after complete or
102-
// have other race conditions. Don't even need a complicated ticker because it
103-
// doesn't need to be interruptible.
104-
for start := time.Now(); time.Since(start) < waitFor; {
105-
if condition() {
106-
return
107-
}
108-
time.Sleep(tick)
109-
}
110-
h.Fail("condition did not evaluate to true within timeout", msgAndArgs...)
111-
}
112-
113-
func (h *CommandHarness) T() *testing.T {
114-
return h.t
115-
}
116-
117-
type CommandResult struct {
118-
Err error
119-
Stdout bytes.Buffer
120-
Stderr bytes.Buffer
121-
}
122-
123-
func (h *CommandHarness) Execute(args ...string) *CommandResult {
124-
// Copy options, update as needed
125-
res := &CommandResult{}
126-
options := h.Options
127-
// Set stdio
128-
options.Stdin = &h.Stdin
129-
options.Stdout = &res.Stdout
130-
options.Stderr = &res.Stderr
131-
// Set args
132-
options.Args = args
133-
// Capture error
134-
options.Fail = func(err error) {
135-
if res.Err != nil {
136-
panic("fail called twice, just failed with " + err.Error())
137-
}
138-
res.Err = err
139-
}
140-
141-
// Run
142-
ctx, cancel := context.WithCancel(h.Context)
143-
h.t.Cleanup(cancel)
144-
defer cancel()
145-
h.t.Logf("Calling: %v", strings.Join(args, " "))
146-
temporalcloudcli.Execute(ctx, options)
147-
if res.Stdout.Len() > 0 {
148-
h.t.Logf("Stdout:\n-----\n%s\n-----", &res.Stdout)
149-
}
150-
if res.Stderr.Len() > 0 {
151-
h.t.Logf("Stderr:\n-----\n%s\n-----", &res.Stderr)
152-
}
153-
return res
154-
}
155-
15625
type EnvLookupMap map[string]string
15726

15827
func (e EnvLookupMap) Environ() []string {

0 commit comments

Comments
 (0)