1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package client_test
4
5import (
6 "context"
7 "errors"
8 "testing"
9 "time"
10
11 "connectrpc.com/connect"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite-api/gen/ermite"
16 "bnema.dev/ermite/internal/adapters/client"
17)
18
19func TestCallsCarryTokenAndUserAgent(t *testing.T) {
20 f := &fake{version: func(context.Context, *connect.Request[ermite.GetVersionRequest]) (*connect.Response[ermite.GetVersionResponse], error) {
21 return connect.NewResponse(&ermite.GetVersionResponse{Version: "v1"}), nil
22 }}
23 c := start(t, f, client.Config{Token: "ermite_secret", UserAgent: "ermite/v9 (cli)"})
24 res, err := c.Server().GetVersion(t.Context(), connect.NewRequest(&ermite.GetVersionRequest{}))
25 require.NoError(t, err)
26 assert.Equal(t, "v1", res.Msg.GetVersion())
27 require.Len(t, f.headers, 1)
28 assert.Equal(t, "Bearer ermite_secret", f.headers[0].Get("Authorization"))
29 assert.Equal(t, "ermite/v9 (cli)", f.headers[0].Get("User-Agent"))
30}
31
32func TestAnonymousCallsSendNoAuthorization(t *testing.T) {
33 f := &fake{version: func(context.Context, *connect.Request[ermite.GetVersionRequest]) (*connect.Response[ermite.GetVersionResponse], error) {
34 return connect.NewResponse(&ermite.GetVersionResponse{}), nil
35 }}
36 c := start(t, f, client.Config{})
37 _, err := c.Server().GetVersion(t.Context(), connect.NewRequest(&ermite.GetVersionRequest{}))
38 require.NoError(t, err)
39 assert.Empty(t, f.headers[0].Values("Authorization"))
40}
41
42func TestUnaryCallsTimeOut(t *testing.T) {
43 f := &fake{version: func(ctx context.Context, _ *connect.Request[ermite.GetVersionRequest]) (*connect.Response[ermite.GetVersionResponse], error) {
44 <-ctx.Done()
45 return nil, ctx.Err()
46 }}
47 c := start(t, f, client.Config{Timeout: 50 * time.Millisecond})
48 start := time.Now()
49 _, err := c.Server().GetVersion(t.Context(), connect.NewRequest(&ermite.GetVersionRequest{}))
50 require.Error(t, err)
51 assert.Less(t, time.Since(start), 5*time.Second)
52 assert.Equal(t, connect.CodeDeadlineExceeded, connect.CodeOf(err))
53 assert.Contains(t, c.Describe(err).Error(), "timed out")
54}
55
56func TestRedirectIsNotFollowed(t *testing.T) {
57 f := &fake{}
58 c := start(t, f, client.Config{Token: "t"})
59 _ = c // the redirect target below is a plain server
60 redirect := redirectingServer(t)
61 rc, err := client.New(client.Config{URL: redirect, Token: "t"})
62 require.NoError(t, err)
63 _, err = rc.Server().GetVersion(t.Context(), connect.NewRequest(&ermite.GetVersionRequest{}))
64 require.Error(t, err)
65 assert.Contains(t, rc.Describe(err).Error(), "redirected")
66}
67
68func TestNewRejectsBadURL(t *testing.T) {
69 _, err := client.New(client.Config{URL: "ftp://x"})
70 require.Error(t, err)
71}
72
73func TestDescribe(t *testing.T) {
74 anon, err := client.New(client.Config{URL: "http://srv.test"})
75 require.NoError(t, err)
76 authed, err := client.New(client.Config{URL: "http://srv.test", Token: "t"})
77 require.NoError(t, err)
78
79 tests := []struct {
80 name string
81 c *client.Client
82 err error
83 want []string
84 }{
85 {"unauthenticated anonymous", anon, connect.NewError(connect.CodeUnauthenticated, errors.New("no token")),
86 []string{"authentication required", "ERMITE_TOKEN"}},
87 {"unauthenticated bad token", authed, connect.NewError(connect.CodeUnauthenticated, errors.New("bad")),
88 []string{"not authenticated", "invalid, expired or revoked"}},
89 {"not found", authed, connect.NewError(connect.CodeNotFound, errors.New("repository \"x\" not found")),
90 []string{"not found: repository \"x\" not found"}},
91 {"permission denied", authed, connect.NewError(connect.CodePermissionDenied, errors.New("missing scope repo:admin")),
92 []string{"permission denied: missing scope repo:admin", "token list"}},
93 {"already exists", authed, connect.NewError(connect.CodeAlreadyExists, errors.New("name taken")),
94 []string{"already exists: name taken"}},
95 {"invalid", authed, connect.NewWireError(connect.CodeInvalidArgument, errors.New("bad name")),
96 []string{"invalid request: bad name"}},
97 {"invalid raised by the client library", authed, connect.NewError(connect.CodeInvalidArgument, errors.New("protocol error: incomplete envelope")),
98 []string{"connection to http://srv.test broke", "incomplete envelope"}},
99 {"precondition", authed, connect.NewError(connect.CodeFailedPrecondition, errors.New("no such branch")),
100 []string{"precondition failed: no such branch"}},
101 {"unavailable", authed, connect.NewError(connect.CodeUnavailable, errors.New("connection refused")),
102 []string{"cannot reach the server at http://srv.test", "connection refused"}},
103 {"unimplemented", authed, connect.NewError(connect.CodeUnimplemented, errors.New("x")),
104 []string{"does not implement", "ermite version"}},
105 {"multi-line message", authed, connect.NewError(connect.CodeInternal, errors.New("line1\nline2\n\tline3")),
106 []string{"server error: line1 line2 line3"}},
107 {"empty message", authed, connect.NewError(connect.CodeNotFound, errors.New("")),
108 []string{"not found"}},
109 }
110 for _, tt := range tests {
111 t.Run(tt.name, func(t *testing.T) {
112 got := tt.c.Describe(tt.err)
113 require.Error(t, got)
114 for _, w := range tt.want {
115 assert.Contains(t, got.Error(), w)
116 }
117 assert.NotContains(t, got.Error(), "\n")
118 assert.NotContains(t, got.Error(), "[", "no connect code prefix")
119 assert.ErrorIs(t, got, tt.err, "the original error stays reachable")
120 })
121 }
122
123 plain := errors.New("boom")
124 assert.Same(t, plain, anon.Describe(plain))
125 assert.NoError(t, anon.Describe(nil))
126}
127
128func TestDescribeBoundsLongMessages(t *testing.T) {
129 c, err := client.New(client.Config{URL: "http://srv.test"})
130 require.NoError(t, err)
131 long := make([]byte, 5000)
132 for i := range long {
133 long[i] = 'a'
134 }
135 got := c.Describe(connect.NewError(connect.CodeInternal, errors.New(string(long))))
136 assert.Less(t, len(got.Error()), 400)
137}
138
139func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,
142 []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch,
147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
148 }))
149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,
151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
152 }))
153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
154}
155
156func TestDescribeStripsTerminalControls(t *testing.T) {
157 c, err := client.New(client.Config{URL: "http://srv.test", Token: "t"})
158 require.NoError(t, err)
159 msg := "bad\x1b]0;pwn\x07 name \u202egpj.exe \u2066x\u2069"
160 got := c.Describe(connect.NewError(connect.CodeInvalidArgument, errors.New(msg))).Error()
161 assert.NotContains(t, got, "\x1b")
162 assert.NotContains(t, got, "\x07")
163 assert.NotContains(t, got, "\u202e")
164 assert.NotContains(t, got, "\u2066")
165 assert.NotContains(t, got, "\u2069")
166 assert.Contains(t, got, "bad?]0;pwn? name ?gpj.exe ?x?")
167}