v0.26.1 dfd6f0b

cli_token_test.go RawHistoryFolder

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

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 2

Tags 44

↑↓ moveenter openesc close