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}