v0.35.1 2aca1df

cli_token_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"errors"
7	"fmt"
8	"strings"
9	"testing"
10	"time"
11
12	"connectrpc.com/connect"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15
16	"bnema.dev/ermite-api/gen/ermite"
17	"bnema.dev/ermite/internal/adapters/client"
18)
19
20func TestTokenCreateMapsFlags(t *testing.T) {
21	f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
22		return &ermite.CreateTokenResponse{
23			Token: &ermite.Token{
24				Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), Repos: r.GetRepos(),
25				ExpiresAt: r.GetExpiresAt(), CreatedAt: ts("2026-10-10T12:00:00Z"),
26			},
27			Secret: "ermite_SECRETVALUE",
28		}, nil
29	}}
30	res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "ci runner",
31		"--preset", "ci", "--scope", "repo:write,mr:write", "--repo", "alpha", "--repo", "beta", "--repo", "alpha", "--expires", "30d").ok(t)
32
33	_, reqs := f.snapshot()
34	got := reqs[0].(*ermite.CreateTokenRequest)
35	assert.Equal(t, "ci runner", got.GetLabel())
36	assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_READ, ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, got.GetScopes())
37	assert.Equal(t, []string{"alpha", "beta"}, got.GetRepos())
38	assert.Equal(t, testNow.Add(30*24*time.Hour), got.GetExpiresAt().AsTime())
39
40	// The secret alone is on stdout, details are on stderr.
41	assert.Equal(t, "ermite_SECRETVALUE\n", res.stdout)
42	assert.Contains(t, res.stderr, `created token tok1 "ci runner"`)
43	assert.Contains(t, res.stderr, "repo:read, repo:write, mr:write")
44	assert.Contains(t, res.stderr, "alpha, beta")
45	assert.Contains(t, res.stderr, "shown once")
46	assert.NotContains(t, res.stderr, "SECRETVALUE")
47}
48
49// lastCreateToken returns the last CreateToken request the fake received.
50func lastCreateToken(t *testing.T, f *fakeAPI) *ermite.CreateTokenRequest {
51	t.Helper()
52	calls, reqs := f.snapshot()
53	for i := len(calls) - 1; i >= 0; i-- {
54		if calls[i] == "CreateToken" {
55			return reqs[i].(*ermite.CreateTokenRequest)
56		}
57	}
58	t.Fatal("no CreateToken call")
59	return nil
60}
61
62// withBranchPrefixes is a GetVersion that lists token.branch_prefixes.
63func withBranchPrefixes(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
64	return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"token.branch_prefixes"}}, nil
65}
66
67func TestTokenCreateBranchPrefixes(t *testing.T) {
68	echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
69		return &ermite.CreateTokenResponse{
70			Token: &ermite.Token{
71				Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), BranchPrefixes: r.GetBranchPrefixes(),
72				CreatedAt: ts("2026-10-10T12:00:00Z"),
73			},
74			Secret: "ermite_S",
75		}, nil
76	}
77
78	t.Run("maps the repeatable flag, trims and drops duplicates", func(t *testing.T) {
79		f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes}
80		res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "claude", "--preset", "agent", "--repo", "alpha",
81			"--branch-prefix", "agent/claude/", "--branch-prefix", " bot/ ", "--branch-prefix", "agent/claude/").ok(t)
82		assert.Equal(t, []string{"agent/claude/", "bot/"}, lastCreateToken(t, f).GetBranchPrefixes())
83		assert.Equal(t, "ermite_S\n", res.stdout)
84		assert.Contains(t, res.stderr, "branches")
85		assert.Contains(t, res.stderr, "agent/claude/, bot/")
86	})
87
88	t.Run("without the flag the token shows every branch", func(t *testing.T) {
89		f := &fakeAPI{createToken: echo}
90		res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t)
91		_, reqs := f.snapshot()
92		assert.Empty(t, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes())
93		assert.Regexp(t, `branches\s+all`, res.stderr)
94	})
95
96	t.Run("json", func(t *testing.T) {
97		f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes}
98		res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:write", "--branch-prefix", "a/", "--json").ok(t)
99		assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:write"],"repos":[],"branch_prefixes":["a/"],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
100	})
101
102	t.Run("a server without the feature is asked nothing", func(t *testing.T) {
103		for _, tt := range []struct {
104			name    string
105			version func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error)
106			wantMsg string
107		}{
108			{"feature missing", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
109				return &ermite.GetVersionResponse{Version: "v0.3.0", Features: []string{"repo.update", "session"}}, nil
110			}, "does not support token.branch_prefixes"},
111			{"version unreadable", nil, "does not implement this call"}, // the fake answers unimplemented
112			{"server unavailable", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
113				return nil, connect.NewError(connect.CodeUnavailable, errors.New("connection refused"))
114			}, "cannot reach the server"},
115		} {
116			f := &fakeAPI{createToken: echo, getVersion: tt.version}
117			res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/")
118			require.Error(t, res.err, tt.name)
119			assert.Contains(t, strings.ToLower(res.err.Error()), tt.wantMsg, tt.name)
120			calls, _ := f.snapshot()
121			assert.NotContains(t, calls, "CreateToken", "%s: no token is created", tt.name)
122			assert.Empty(t, res.stdout, tt.name)
123		}
124	})
125
126	t.Run("without --branch-prefix the features are not read", func(t *testing.T) {
127		f := &fakeAPI{createToken: echo} // no getVersion: it would answer unimplemented
128		runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t)
129		calls, _ := f.snapshot()
130		assert.NotContains(t, calls, "GetVersion")
131	})
132
133	t.Run("a server that ignores the field does not hand out an unlimited token", func(t *testing.T) {
134		f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
135			return &ermite.CreateTokenResponse{
136				Token:  &ermite.Token{Id: "tok9", Label: r.GetLabel(), Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")},
137				Secret: "ermite_UNLIMITED",
138			}, nil
139		}, getVersion: withBranchPrefixes}
140		res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/")
141		require.Error(t, res.err)
142		assert.Contains(t, res.err.Error(), "without the requested branch prefixes")
143		assert.Contains(t, res.err.Error(), "ermite token revoke tok9")
144		assert.NotContains(t, res.stdout, "UNLIMITED")
145		assert.NotContains(t, res.stderr, "UNLIMITED")
146		assert.NotContains(t, res.err.Error(), "UNLIMITED")
147	})
148}
149
150func TestTokenListBranchPrefixes(t *testing.T) {
151	f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
152		return &ermite.ListTokensResponse{Tokens: []*ermite.Token{
153			{Id: "t2", Label: "claude", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, CreatedAt: ts("2026-10-01T00:00:00Z")},
154			{Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")},
155		}}, nil
156	}}
157	url := f.serve(t)
158	res := runCLI(t, envFor(url), "", "token", "list").ok(t)
159	assert.Equal(t, ""+
160		"ID  LABEL   SCOPES      REPOS  BRANCHES            EXPIRES  LAST USED\n"+
161		"t2  claude  repo:write  all    agent/claude/,bot/  never    never\n"+
162		"t1  ci      repo:read   all    all                 never    never\n", res.stdout)
163
164	res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
165	assert.JSONEq(t, `[
166		{"id":"t2","label":"claude","scopes":["repo:write"],"repos":[],"branch_prefixes":["agent/claude/","bot/"],"expires_at":null,"created_at":"2026-10-01T00:00:00Z","last_used_at":null},
167		{"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
168	]`, res.stdout)
169}
170
171func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
172	for preset, want := range map[string][]ermite.Scope{
173		"agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE},
174		"ci":    {ermite.Scope_SCOPE_REPO_READ},
175		"cli":   client.AllScopes(),
176	} {
177		t.Run(preset, func(t *testing.T) {
178			f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
179				return &ermite.GetVersionResponse{Features: []string{"issues", "releases"}}, nil
180			}, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
181				return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
182			}}
183			runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)
184			got := lastCreateToken(t, f)
185			assert.ElementsMatch(t, want, got.GetScopes())
186			assert.Empty(t, got.GetRepos())
187			assert.Nil(t, got.GetExpiresAt())
188		})
189	}
190}
191
192func TestTokenCreateJSON(t *testing.T) {
193	f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
194		return &ermite.CreateTokenResponse{
195			Token:  &ermite.Token{Id: "tok1", Label: "l", Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")},
196			Secret: "ermite_S",
197		}, nil
198	}}
199	res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t)
200	assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
201	assert.Empty(t, res.stderr)
202}
203
204func TestTokenCreateValidatesBeforeCalling(t *testing.T) {
205	f := &fakeAPI{}
206	url := f.serve(t)
207	for _, tt := range []struct {
208		args []string
209		want string
210	}{
211		{[]string{"x"}, "--preset"},
212		{[]string{"x", "--preset", "god"}, "unknown preset"},
213		{[]string{"x", "--scope", "nope"}, "unknown scope"},
214		{[]string{"x", "--preset", "ci", "--expires", "yesterday"}, "--expires"},
215		{[]string{" ", "--preset", "ci"}, "label"},
216	} {
217		res := runCLI(t, envFor(url), "", append([]string{"token", "create"}, tt.args...)...)
218		require.Error(t, res.err, "%v", tt.args)
219		assert.Contains(t, res.err.Error(), tt.want)
220	}
221	calls, _ := f.snapshot()
222	assert.Empty(t, calls)
223}
224
225func TestTokenList(t *testing.T) {
226	f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
227		return &ermite.ListTokensResponse{Tokens: []*ermite.Token{
228			{Id: "t2", Label: "laptop", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ, ermite.Scope_SCOPE_REPO_WRITE},
229				Repos: []string{"alpha", "beta"}, ExpiresAt: ts("2027-01-01T00:00:00Z"), CreatedAt: ts("2026-10-01T00:00:00Z"), LastUsedAt: ts("2026-10-09T08:30:00Z")},
230			{Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")},
231		}}, nil
232	}}
233	url := f.serve(t)
234	res := runCLI(t, envFor(url), "", "token", "list").ok(t)
235	assert.Equal(t, ""+
236		"ID  LABEL   SCOPES                REPOS       BRANCHES  EXPIRES           LAST USED\n"+
237		"t2  laptop  repo:read,repo:write  alpha,beta  all       2027-01-01 00:00  2026-10-09 08:30\n"+
238		"t1  ci      repo:read             all         all       never             never\n", res.stdout)
239
240	res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
241	assert.JSONEq(t, `[
242		{"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"branch_prefixes":[],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},
243		{"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
244	]`, res.stdout)
245	assert.NotContains(t, res.stdout, "secret")
246}
247
248func TestTokenRevoke(t *testing.T) {
249	f := &fakeAPI{revokeToken: func(*ermite.RevokeTokenRequest) (*ermite.RevokeTokenResponse, error) {
250		return &ermite.RevokeTokenResponse{}, nil
251	}}
252	res := runCLI(t, envFor(f.serve(t)), "", "token", "revoke", "t2").ok(t)
253	assert.Equal(t, "revoked t2\n", res.stdout)
254	_, reqs := f.snapshot()
255	assert.Equal(t, "t2", reqs[0].(*ermite.RevokeTokenRequest).GetId())
256}
257
258func TestTokenCompletion(t *testing.T) {
259	f := &fakeAPI{
260		listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
261			return &ermite.ListTokensResponse{Tokens: []*ermite.Token{{Id: "abc1", Label: "laptop"}, {Id: "xyz2", Label: "ci"}}}, nil
262		},
263		listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
264			return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
265		},
266	}
267	vars := envFor(f.serve(t))
268	res := runCLI(t, vars, "", "__complete", "token", "revoke", "a").ok(t)
269	assert.Contains(t, res.stdout, "abc1\tlaptop")
270	assert.NotContains(t, res.stdout, "xyz2")
271
272	res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--preset", "").ok(t)
273	assert.Contains(t, res.stdout, "agent")
274	res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--scope", "repo:").ok(t)
275	assert.Contains(t, res.stdout, "repo:read")
276	res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--repo", "").ok(t)
277	assert.Contains(t, res.stdout, "alpha")
278}
279
280func TestTokenReleaseWriteFeatureGuard(t *testing.T) {
281	for _, supported := range []bool{false, true} {
282		t.Run(fmt.Sprint(supported), func(t *testing.T) {
283			f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
284				features := []string{"issues"}
285				if supported {
286					features = append(features, "releases")
287				}
288				return &ermite.GetVersionResponse{Features: features}, nil
289			}, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
290				return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
291			}}
292			vars := envFor(f.serve(t))
293			res := runCLI(t, vars, "", "token", "create", "daily", "--preset", "cli").ok(t)
294			got := lastCreateToken(t, f).GetScopes()
295			if supported {
296				assert.Contains(t, got, ermite.Scope_SCOPE_RELEASE_WRITE)
297				assert.NotContains(t, res.stderr, "no release:write")
298			} else {
299				assert.NotContains(t, got, ermite.Scope_SCOPE_RELEASE_WRITE)
300				assert.Contains(t, res.stderr, "the server has no releases feature, so the token has no release:write scope")
301			}
302			before, _ := f.snapshot()
303			res = runCLI(t, vars, "", "token", "create", "explicit", "--scope", "release:write")
304			if supported {
305				res.ok(t)
306			} else {
307				require.ErrorIs(t, res.err, errNoReleases)
308				after, _ := f.snapshot()
309				assert.Equal(t, len(before)+1, len(after))
310				assert.Equal(t, "GetVersion", after[len(after)-1])
311			}
312		})
313	}
314}

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