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}