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}