v0.35.0 e86dbd6

client_test.go RawHistoryFolder

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.FeatureReleases, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge},
143		client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144	assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145		client.FeatureReleases, client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
146		client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147		client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge,
148	}))
149	assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150		client.FeatureReleases, client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
151		client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge,
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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close