1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "strings"
8 "testing"
9 "time"
10
11 "connectrpc.com/connect"
12 "github.com/stretchr/testify/assert"
13
14 "bnema.dev/ermite-api/gen/ermite"
15)
16
17// The RunService calls of the `run` and `runner` commands answer with empty
18// results: these tests look at which address and token reach the server, not
19// at the output.
20func (f *fakeAPI) ListRuns(_ context.Context, r *connect.Request[ermite.ListRunsRequest]) (*connect.Response[ermite.ListRunsResponse], error) {
21 return respond(f, "ListRuns", r, func(*ermite.ListRunsRequest) (*ermite.ListRunsResponse, error) {
22 return &ermite.ListRunsResponse{}, nil
23 })
24}
25
26func (f *fakeAPI) CancelRun(_ context.Context, r *connect.Request[ermite.CancelRunRequest]) (*connect.Response[ermite.CancelRunResponse], error) {
27 return respond(f, "CancelRun", r, func(*ermite.CancelRunRequest) (*ermite.CancelRunResponse, error) {
28 return &ermite.CancelRunResponse{}, nil
29 })
30}
31
32func (f *fakeAPI) RerunRun(_ context.Context, r *connect.Request[ermite.RerunRunRequest]) (*connect.Response[ermite.RerunRunResponse], error) {
33 return respond(f, "RerunRun", r, func(*ermite.RerunRunRequest) (*ermite.RerunRunResponse, error) {
34 return &ermite.RerunRunResponse{}, nil
35 })
36}
37
38func (f *fakeAPI) ListRunners(_ context.Context, r *connect.Request[ermite.ListRunnersRequest]) (*connect.Response[ermite.ListRunnersResponse], error) {
39 return respond(f, "ListRunners", r, func(*ermite.ListRunnersRequest) (*ermite.ListRunnersResponse, error) {
40 if f.runners != nil {
41 return &ermite.ListRunnersResponse{Runners: f.runners}, nil
42 }
43 return &ermite.ListRunnersResponse{Runners: []*ermite.Runner{{Id: "r1", Name: "alpha"}}}, nil
44 })
45}
46
47func (f *fakeAPI) RemoveRunner(_ context.Context, r *connect.Request[ermite.RemoveRunnerRequest]) (*connect.Response[ermite.RemoveRunnerResponse], error) {
48 return respond(f, "RemoveRunner", r, func(*ermite.RemoveRunnerRequest) (*ermite.RemoveRunnerResponse, error) {
49 return &ermite.RemoveRunnerResponse{}, nil
50 })
51}
52
53// The `run` and `runner` commands read the server address and the token like
54// every other remote command: the config file (url, token.cmd), with
55// ERMITE_URL / ERMITE_TOKEN winning, and token.cmd run once and only for the
56// file's own url.
57func TestCIcommandsUseTheConfigFile(t *testing.T) {
58 commands := map[string][]string{
59 "run list": {"run", "list", "proj"},
60 "run cancel": {"run", "cancel", "proj", "1"},
61 "run rerun": {"run", "rerun", "proj", "1"},
62 "runner list": {"runner", "list"},
63 "runner remove": {"runner", "remove", "r1"},
64 }
65 for name, args := range commands {
66 t.Run(name, func(t *testing.T) {
67 f := &fakeAPI{}
68 url := f.serve(t)
69
70 // url and token.cmd from the file
71 env := testEnv(t, nil)
72 writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
73 ran := tokenCmdStub(env, "ermite_cmd\n", nil)
74 runCLIWith(t, env, "", args...).ok(t)
75 assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
76 assert.Len(t, *ran, 1)
77
78 // ERMITE_TOKEN wins and token.cmd stays idle
79 env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
80 writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
81 ran = tokenCmdStub(env, "ermite_cmd", nil)
82 runCLIWith(t, env, "", args...).ok(t)
83 assert.Equal(t, "Bearer ermite_env", lastAuth(f))
84 assert.Empty(t, *ran)
85
86 // another ERMITE_URL never receives the file's token
87 other := &fakeAPI{}
88 env = testEnv(t, map[string]string{"ERMITE_URL": other.serve(t)})
89 writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
90 ran = tokenCmdStub(env, "ermite_cmd", nil)
91 runCLIWith(t, env, "", args...).ok(t)
92 assert.Empty(t, lastAuth(other))
93 assert.Empty(t, *ran)
94 })
95 }
96}
97
98// Completing a repository name for `run` or a runner id for `runner remove`
99// must not run token.cmd: it may prompt, and a completion script cannot answer.
100func TestCICompletionNeverRunsTokenCmd(t *testing.T) {
101 f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
102 return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
103 }}
104 url := f.serve(t)
105 for name, args := range map[string][]string{
106 "run list": {"__complete", "run", "list", ""},
107 "run cancel": {"__complete", "run", "cancel", ""},
108 "runner remove": {"__complete", "runner", "remove", ""},
109 } {
110 t.Run(name, func(t *testing.T) {
111 env := testEnv(t, nil)
112 writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
113 ran := tokenCmdStub(env, "ermite_cmd", nil)
114 runCLIWith(t, env, "", args...).ok(t)
115 assert.Empty(t, *ran)
116 assert.Empty(t, lastAuth(f))
117 })
118 }
119}
120
121// The CLI shows what the server decided: it never compares last_seen with its
122// own clock.
123func TestRunnerListShowsTheServersVerdict(t *testing.T) {
124 f := &fakeAPI{}
125 f.runners = []*ermite.Runner{
126 {Id: "r1", Name: "up", Version: "v2", Online: true},
127 {Id: "r2", Name: "busy", Online: true, Busy: true},
128 {Id: "r3", Name: "down"},
129 }
130 env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t), "ERMITE_TOKEN": "ermite_x"})
131 env.now = func() time.Time { return time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) } // a clock that is far off
132 out := runCLIWith(t, env, "", "runner", "list").ok(t).stdout
133 state := map[string]string{}
134 for _, line := range strings.Split(strings.TrimSpace(out), "\n")[1:] {
135 fields := strings.Fields(line)
136 state[fields[1]] = fields[2]
137 }
138 assert.Equal(t, map[string]string{"up": "online", "busy": "busy", "down": "offline"}, state, out)
139
140 js := runCLIWith(t, env, "", "runner", "list", "--json").ok(t).stdout
141 assert.Contains(t, js, `"online": true`)
142}