1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "bytes"
7 "context"
8 "errors"
9 "net/http"
10 "net/http/httptest"
11 "path/filepath"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 "connectrpc.com/connect"
18 "github.com/stretchr/testify/require"
19 "google.golang.org/protobuf/types/known/timestamppb"
20
21 "bnema.dev/ermite-api/gen/ermite"
22 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
23)
24
25// fakeAPI is a Connect server with stub services: each field, when set,
26// answers the matching RPC; the others answer unimplemented. It records the
27// requests it receives.
28type fakeAPI struct {
29 ermiteconnect.UnimplementedRepoServiceHandler
30 ermiteconnect.UnimplementedTokenServiceHandler
31 ermiteconnect.UnimplementedServerServiceHandler
32 ermiteconnect.UnimplementedEventServiceHandler
33 ermiteconnect.UnimplementedMergeRequestServiceHandler
34 ermiteconnect.UnimplementedSessionServiceHandler
35 ermiteconnect.UnimplementedRunServiceHandler
36 ermiteconnect.UnimplementedTrustedKeyServiceHandler
37
38 runners []*ermite.Runner // ListRunners answer; nil means one default runner
39
40 mu sync.Mutex
41 calls []string // procedure names, in order
42 reqs []any // request messages, in order
43 header []http.Header // request headers, in order
44
45 listRepos func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error)
46 getRepo func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error)
47 createRepo func(*ermite.CreateRepoRequest) (*ermite.CreateRepoResponse, error)
48 updateRepo func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error)
49 deleteRepo func(*ermite.DeleteRepoRequest) (*ermite.DeleteRepoResponse, error)
50 createToken func(*ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error)
51 listTokens func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error)
52 revokeToken func(*ermite.RevokeTokenRequest) (*ermite.RevokeTokenResponse, error)
53 getVersion func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error)
54 watch func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error
55
56 createMR func(*ermite.CreateMergeRequestRequest) (*ermite.CreateMergeRequestResponse, error)
57 listMR func(*ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error)
58 getMR func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error)
59 addComment func(*ermite.AddCommentRequest) (*ermite.AddCommentResponse, error)
60 resolveCmt func(*ermite.ResolveCommentRequest) (*ermite.ResolveCommentResponse, error)
61 closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error)
62 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)
63 markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)
64 getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)
65
66 listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error)
67 removePasskey func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error)
68
69 listTrustedKeys func(*ermite.ListTrustedKeysRequest) (*ermite.ListTrustedKeysResponse, error)
70 addTrustedKey func(*ermite.AddTrustedKeyRequest) (*ermite.AddTrustedKeyResponse, error)
71 removeTrustedKey func(*ermite.RemoveTrustedKeyRequest) (*ermite.RemoveTrustedKeyResponse, error)
72}
73
74func (f *fakeAPI) record(name string, h http.Header, req any) {
75 f.mu.Lock()
76 defer f.mu.Unlock()
77 f.calls = append(f.calls, name)
78 f.reqs = append(f.reqs, req)
79 f.header = append(f.header, h.Clone())
80}
81
82func (f *fakeAPI) snapshot() (calls []string, reqs []any) {
83 f.mu.Lock()
84 defer f.mu.Unlock()
85 return append([]string(nil), f.calls...), append([]any(nil), f.reqs...)
86}
87
88func respond[Req, Res any](f *fakeAPI, name string, req *connect.Request[Req], fn func(*Req) (*Res, error)) (*connect.Response[Res], error) {
89 f.record(name, req.Header(), req.Msg)
90 if fn == nil {
91 return nil, connect.NewError(connect.CodeUnimplemented, nil)
92 }
93 res, err := fn(req.Msg)
94 if err != nil {
95 return nil, err
96 }
97 return connect.NewResponse(res), nil
98}
99
100func (f *fakeAPI) ListRepos(_ context.Context, r *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error) {
101 return respond(f, "ListRepos", r, f.listRepos)
102}
103func (f *fakeAPI) GetRepo(_ context.Context, r *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error) {
104 return respond(f, "GetRepo", r, f.getRepo)
105}
106func (f *fakeAPI) CreateRepo(_ context.Context, r *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error) {
107 return respond(f, "CreateRepo", r, f.createRepo)
108}
109func (f *fakeAPI) UpdateRepo(_ context.Context, r *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error) {
110 return respond(f, "UpdateRepo", r, f.updateRepo)
111}
112func (f *fakeAPI) DeleteRepo(_ context.Context, r *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error) {
113 return respond(f, "DeleteRepo", r, f.deleteRepo)
114}
115func (f *fakeAPI) CreateToken(_ context.Context, r *connect.Request[ermite.CreateTokenRequest]) (*connect.Response[ermite.CreateTokenResponse], error) {
116 return respond(f, "CreateToken", r, f.createToken)
117}
118func (f *fakeAPI) ListTokens(_ context.Context, r *connect.Request[ermite.ListTokensRequest]) (*connect.Response[ermite.ListTokensResponse], error) {
119 return respond(f, "ListTokens", r, f.listTokens)
120}
121func (f *fakeAPI) RevokeToken(_ context.Context, r *connect.Request[ermite.RevokeTokenRequest]) (*connect.Response[ermite.RevokeTokenResponse], error) {
122 return respond(f, "RevokeToken", r, f.revokeToken)
123}
124func (f *fakeAPI) GetVersion(_ context.Context, r *connect.Request[ermite.GetVersionRequest]) (*connect.Response[ermite.GetVersionResponse], error) {
125 return respond(f, "GetVersion", r, f.getVersion)
126}
127func (f *fakeAPI) CreateMergeRequest(_ context.Context, r *connect.Request[ermite.CreateMergeRequestRequest]) (*connect.Response[ermite.CreateMergeRequestResponse], error) {
128 return respond(f, "CreateMergeRequest", r, f.createMR)
129}
130func (f *fakeAPI) ListMergeRequests(_ context.Context, r *connect.Request[ermite.ListMergeRequestsRequest]) (*connect.Response[ermite.ListMergeRequestsResponse], error) {
131 return respond(f, "ListMergeRequests", r, f.listMR)
132}
133func (f *fakeAPI) GetMergeRequest(_ context.Context, r *connect.Request[ermite.GetMergeRequestRequest]) (*connect.Response[ermite.GetMergeRequestResponse], error) {
134 return respond(f, "GetMergeRequest", r, f.getMR)
135}
136func (f *fakeAPI) AddComment(_ context.Context, r *connect.Request[ermite.AddCommentRequest]) (*connect.Response[ermite.AddCommentResponse], error) {
137 return respond(f, "AddComment", r, f.addComment)
138}
139func (f *fakeAPI) ResolveComment(_ context.Context, r *connect.Request[ermite.ResolveCommentRequest]) (*connect.Response[ermite.ResolveCommentResponse], error) {
140 return respond(f, "ResolveComment", r, f.resolveCmt)
141}
142func (f *fakeAPI) CloseMergeRequest(_ context.Context, r *connect.Request[ermite.CloseMergeRequestRequest]) (*connect.Response[ermite.CloseMergeRequestResponse], error) {
143 return respond(f, "CloseMergeRequest", r, f.closeMR)
144}
145func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) {
146 return respond(f, "MergeMergeRequest", r, f.mergeMR)
147}
148func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) {
149 return respond(f, "MarkRead", r, f.markRead)
150}
151func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) {
152 return respond(f, "GetSigningKey", r, f.getSigningKey)
153}
154func (f *fakeAPI) ListPasskeys(_ context.Context, r *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error) {
155 return respond(f, "ListPasskeys", r, f.listPasskeys)
156}
157func (f *fakeAPI) RemovePasskey(_ context.Context, r *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) {
158 return respond(f, "RemovePasskey", r, f.removePasskey)
159}
160func (f *fakeAPI) ListTrustedKeys(_ context.Context, r *connect.Request[ermite.ListTrustedKeysRequest]) (*connect.Response[ermite.ListTrustedKeysResponse], error) {
161 return respond(f, "ListTrustedKeys", r, f.listTrustedKeys)
162}
163func (f *fakeAPI) AddTrustedKey(_ context.Context, r *connect.Request[ermite.AddTrustedKeyRequest]) (*connect.Response[ermite.AddTrustedKeyResponse], error) {
164 return respond(f, "AddTrustedKey", r, f.addTrustedKey)
165}
166func (f *fakeAPI) RemoveTrustedKey(_ context.Context, r *connect.Request[ermite.RemoveTrustedKeyRequest]) (*connect.Response[ermite.RemoveTrustedKeyResponse], error) {
167 return respond(f, "RemoveTrustedKey", r, f.removeTrustedKey)
168}
169func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error {
170 f.record("Watch", r.Header(), r.Msg)
171 if f.watch == nil {
172 return connect.NewError(connect.CodeUnimplemented, nil)
173 }
174 return f.watch(ctx, r.Msg, st)
175}
176
177// serve starts the fake under /-/api and returns its base URL.
178func (f *fakeAPI) serve(t *testing.T) string {
179 t.Helper()
180 mux := http.NewServeMux()
181 mount := func(path string, h http.Handler) { mux.Handle("/-/api"+path, http.StripPrefix("/-/api", h)) }
182 p, h := ermiteconnect.NewRepoServiceHandler(f)
183 mount(p, h)
184 p, h = ermiteconnect.NewTokenServiceHandler(f)
185 mount(p, h)
186 p, h = ermiteconnect.NewServerServiceHandler(f)
187 mount(p, h)
188 p, h = ermiteconnect.NewEventServiceHandler(f)
189 mount(p, h)
190 p, h = ermiteconnect.NewMergeRequestServiceHandler(f)
191 mount(p, h)
192 p, h = ermiteconnect.NewSessionServiceHandler(f)
193 mount(p, h)
194 p, h = ermiteconnect.NewRunServiceHandler(f)
195 mount(p, h)
196 p, h = ermiteconnect.NewTrustedKeyServiceHandler(f)
197 mount(p, h)
198 srv := httptest.NewServer(mux)
199 t.Cleanup(srv.Close)
200 return srv.URL
201}
202
203var testNow = time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
204
205// result is the outcome of one CLI run.
206type result struct {
207 stdout, stderr string
208 err error
209}
210
211// testEnv is a cliEnv with the given variables, the fixed clock, and a config
212// file path inside a fresh temp dir (no file yet) so that a test never reads
213// the developer's own ~/.config/ermite/config. token.cmd must not run unless
214// the test sets runTokenCmd.
215func testEnv(t *testing.T, vars map[string]string) *cliEnv {
216 t.Helper()
217 path := filepath.Join(t.TempDir(), "ermite", "config")
218 return &cliEnv{
219 getenv: func(k string) string { return vars[k] },
220 now: func() time.Time { return testNow },
221 version: "v0.0.1",
222 sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() },
223 configPath: func() (string, error) { return path, nil },
224 runTokenCmd: func(context.Context, string) (string, error) {
225 t.Error("token.cmd must not run")
226 return "", errors.New("token.cmd must not run")
227 },
228 }
229}
230
231// runCLI runs the command tree in process with the given environment.
232func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result {
233 t.Helper()
234 return runCLIWith(t, testEnv(t, vars), stdin, args...)
235}
236
237// runCLIWith runs the command tree in process with env.
238func runCLIWith(t *testing.T, env *cliEnv, stdin string, args ...string) result {
239 t.Helper()
240 root := newRootCmdWith(env)
241 var out, errb bytes.Buffer
242 root.SetOut(&out)
243 root.SetErr(&errb)
244 root.SetIn(strings.NewReader(stdin))
245 root.SetArgs(args)
246 ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
247 defer cancel()
248 err := root.ExecuteContext(ctx)
249 return result{stdout: out.String(), stderr: errb.String(), err: err}
250}
251
252func (r result) ok(t *testing.T) result {
253 t.Helper()
254 require.NoError(t, r.err, "stderr: %s", r.stderr)
255 return r
256}
257
258func ts(s string) *timestamppb.Timestamp {
259 v, err := time.Parse(time.RFC3339, s)
260 if err != nil {
261 panic(err)
262 }
263 return timestamppb.New(v)
264}