v0.28.1 cc7ba64

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{getVersion: withIssues, 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			got := lastCreateToken(t, f)
182			assert.ElementsMatch(t, want, got.GetScopes())
183			assert.Empty(t, got.GetRepos())
184			assert.Nil(t, got.GetExpiresAt())
185		})
186	}
187}
188
189func TestTokenCreateJSON(t *testing.T) {
190	f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
191		return &ermite.CreateTokenResponse{
192			Token:  &ermite.Token{Id: "tok1", Label: "l", Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")},
193			Secret: "ermite_S",
194		}, nil
195	}}
196	res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t)
197	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)
198	assert.Empty(t, res.stderr)
199}
200
201func TestTokenCreateValidatesBeforeCalling(t *testing.T) {
202	f := &fakeAPI{}
203	url := f.serve(t)
204	for _, tt := range []struct {
205		args []string
206		want string
207	}{
208		{[]string{"x"}, "--preset"},
209		{[]string{"x", "--preset", "god"}, "unknown preset"},
210		{[]string{"x", "--scope", "nope"}, "unknown scope"},
211		{[]string{"x", "--preset", "ci", "--expires", "yesterday"}, "--expires"},
212		{[]string{" ", "--preset", "ci"}, "label"},
213	} {
214		res := runCLI(t, envFor(url), "", append([]string{"token", "create"}, tt.args...)...)
215		require.Error(t, res.err, "%v", tt.args)
216		assert.Contains(t, res.err.Error(), tt.want)
217	}
218	calls, _ := f.snapshot()
219	assert.Empty(t, calls)
220}
221
222func TestTokenList(t *testing.T) {
223	f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
224		return &ermite.ListTokensResponse{Tokens: []*ermite.Token{
225			{Id: "t2", Label: "laptop", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ, ermite.Scope_SCOPE_REPO_WRITE},
226				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")},
227			{Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")},
228		}}, nil
229	}}
230	url := f.serve(t)
231	res := runCLI(t, envFor(url), "", "token", "list").ok(t)
232	assert.Equal(t, ""+
233		"ID  LABEL   SCOPES                REPOS       BRANCHES  EXPIRES           LAST USED\n"+
234		"t2  laptop  repo:read,repo:write  alpha,beta  all       2027-01-01 00:00  2026-10-09 08:30\n"+
235		"t1  ci      repo:read             all         all       never             never\n", res.stdout)
236
237	res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
238	assert.JSONEq(t, `[
239		{"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"},
240		{"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
241	]`, res.stdout)
242	assert.NotContains(t, res.stdout, "secret")
243}
244
245func TestTokenRevoke(t *testing.T) {
246	f := &fakeAPI{revokeToken: func(*ermite.RevokeTokenRequest) (*ermite.RevokeTokenResponse, error) {
247		return &ermite.RevokeTokenResponse{}, nil
248	}}
249	res := runCLI(t, envFor(f.serve(t)), "", "token", "revoke", "t2").ok(t)
250	assert.Equal(t, "revoked t2\n", res.stdout)
251	_, reqs := f.snapshot()
252	assert.Equal(t, "t2", reqs[0].(*ermite.RevokeTokenRequest).GetId())
253}
254
255func TestTokenCompletion(t *testing.T) {
256	f := &fakeAPI{
257		listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
258			return &ermite.ListTokensResponse{Tokens: []*ermite.Token{{Id: "abc1", Label: "laptop"}, {Id: "xyz2", Label: "ci"}}}, nil
259		},
260		listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
261			return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
262		},
263	}
264	vars := envFor(f.serve(t))
265	res := runCLI(t, vars, "", "__complete", "token", "revoke", "a").ok(t)
266	assert.Contains(t, res.stdout, "abc1\tlaptop")
267	assert.NotContains(t, res.stdout, "xyz2")
268
269	res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--preset", "").ok(t)
270	assert.Contains(t, res.stdout, "agent")
271	res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--scope", "repo:").ok(t)
272	assert.Contains(t, res.stdout, "repo:read")
273	res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--repo", "").ok(t)
274	assert.Contains(t, res.stdout, "alpha")
275}

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 3

Tags 44

↑↓ moveenter openesc close