1// SPDX-License-Identifier: MIT
2
3package api_test
4
5import (
6 "errors"
7 "testing"
8
9 "connectrpc.com/connect"
10
11 "google.golang.org/protobuf/proto"
12 "google.golang.org/protobuf/reflect/protoreflect"
13 "google.golang.org/protobuf/types/known/fieldmaskpb"
14
15 "bnema.dev/ermite-api/gen/ermite"
16 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17)
18
19// TestProcedures pins the procedure paths: they are the wire contract.
20func TestProcedures(t *testing.T) {
21 tests := []struct{ got, want string }{
22 {ermiteconnect.RepoServiceListReposProcedure, "/ermite.RepoService/ListRepos"},
23 {ermiteconnect.RepoServiceGetRepoProcedure, "/ermite.RepoService/GetRepo"},
24 {ermiteconnect.RepoServiceCreateRepoProcedure, "/ermite.RepoService/CreateRepo"},
25 {ermiteconnect.RepoServiceUpdateRepoProcedure, "/ermite.RepoService/UpdateRepo"},
26 {ermiteconnect.RepoServiceDeleteRepoProcedure, "/ermite.RepoService/DeleteRepo"},
27 {ermiteconnect.GitServiceListRefsProcedure, "/ermite.GitService/ListRefs"},
28 {ermiteconnect.GitServiceGetTreeProcedure, "/ermite.GitService/GetTree"},
29 {ermiteconnect.GitServiceListPathsProcedure, "/ermite.GitService/ListPaths"},
30 {ermiteconnect.GitServiceGetBlobProcedure, "/ermite.GitService/GetBlob"},
31 {ermiteconnect.GitServiceListCommitsProcedure, "/ermite.GitService/ListCommits"},
32 {ermiteconnect.GitServiceGetCommitProcedure, "/ermite.GitService/GetCommit"},
33 {ermiteconnect.GitServiceResolveRevisionProcedure, "/ermite.GitService/ResolveRevision"},
34 {ermiteconnect.TokenServiceCreateTokenProcedure, "/ermite.TokenService/CreateToken"},
35 {ermiteconnect.TokenServiceListTokensProcedure, "/ermite.TokenService/ListTokens"},
36 {ermiteconnect.TokenServiceRevokeTokenProcedure, "/ermite.TokenService/RevokeToken"},
37 {ermiteconnect.EventServiceWatchProcedure, "/ermite.EventService/Watch"},
38 {ermiteconnect.MergeRequestServiceCreateMergeRequestProcedure, "/ermite.MergeRequestService/CreateMergeRequest"},
39 {ermiteconnect.MergeRequestServiceListMergeRequestsProcedure, "/ermite.MergeRequestService/ListMergeRequests"},
40 {ermiteconnect.MergeRequestServiceGetMergeRequestProcedure, "/ermite.MergeRequestService/GetMergeRequest"},
41 {ermiteconnect.MergeRequestServiceAddCommentProcedure, "/ermite.MergeRequestService/AddComment"},
42 {ermiteconnect.MergeRequestServiceResolveCommentProcedure, "/ermite.MergeRequestService/ResolveComment"},
43 {ermiteconnect.MergeRequestServiceCloseMergeRequestProcedure, "/ermite.MergeRequestService/CloseMergeRequest"},
44 {ermiteconnect.MergeRequestServiceMergeMergeRequestProcedure, "/ermite.MergeRequestService/MergeMergeRequest"},
45 {ermiteconnect.MergeRequestServiceMarkReadProcedure, "/ermite.MergeRequestService/MarkRead"},
46 {ermiteconnect.SessionServiceCreateLoginLinkProcedure, "/ermite.SessionService/CreateLoginLink"},
47 {ermiteconnect.SessionServiceSignOutEverywhereProcedure, "/ermite.SessionService/SignOutEverywhere"},
48 {ermiteconnect.ServerServiceGetVersionProcedure, "/ermite.ServerService/GetVersion"},
49 {ermiteconnect.ServerServiceGetSigningKeyProcedure, "/ermite.ServerService/GetSigningKey"},
50 {ermiteconnect.RunnerServiceRegisterRunnerProcedure, "/ermite.RunnerService/RegisterRunner"},
51 {ermiteconnect.RunnerServiceFetchJobProcedure, "/ermite.RunnerService/FetchJob"},
52 {ermiteconnect.RunnerServiceUpdateJobProcedure, "/ermite.RunnerService/UpdateJob"},
53 {ermiteconnect.RunnerServiceAppendLogProcedure, "/ermite.RunnerService/AppendLog"},
54 {ermiteconnect.RunnerServiceHeartbeatProcedure, "/ermite.RunnerService/Heartbeat"},
55 {ermiteconnect.RunnerServiceWatchJobProcedure, "/ermite.RunnerService/WatchJob"},
56 {ermiteconnect.RunnerServiceCompleteJobProcedure, "/ermite.RunnerService/CompleteJob"},
57 {ermiteconnect.RunServiceListRunsProcedure, "/ermite.RunService/ListRuns"},
58 {ermiteconnect.RunServiceGetRunProcedure, "/ermite.RunService/GetRun"},
59 {ermiteconnect.RunServiceGetJobProcedure, "/ermite.RunService/GetJob"},
60 {ermiteconnect.RunServiceGetJobLogProcedure, "/ermite.RunService/GetJobLog"},
61 {ermiteconnect.RunServiceCancelRunProcedure, "/ermite.RunService/CancelRun"},
62 {ermiteconnect.RunServiceRerunRunProcedure, "/ermite.RunService/RerunRun"},
63 {ermiteconnect.RunServiceListChecksProcedure, "/ermite.RunService/ListChecks"},
64 {ermiteconnect.RunServiceListRunnersProcedure, "/ermite.RunService/ListRunners"},
65 {ermiteconnect.RunServiceRemoveRunnerProcedure, "/ermite.RunService/RemoveRunner"},
66 }
67 for _, tt := range tests {
68 if tt.got != tt.want {
69 t.Errorf("procedure = %q, want %q", tt.got, tt.want)
70 }
71 }
72}
73
74// TestEnumNumbers pins enum numbers: they are part of the wire contract.
75func TestEnumNumbers(t *testing.T) {
76 if got := ermite.Scope_SCOPE_TOKEN_ADMIN; got != 9 {
77 t.Errorf("SCOPE_TOKEN_ADMIN = %d, want 9", got)
78 }
79 if got := ermite.FileStatus_FILE_STATUS_TYPE_CHANGED; got != 6 {
80 t.Errorf("FILE_STATUS_TYPE_CHANGED = %d, want 6", got)
81 }
82 if got := ermite.Visibility_VISIBILITY_PRIVATE; got != 2 {
83 t.Errorf("VISIBILITY_PRIVATE = %d, want 2", got)
84 }
85 if got := ermite.MergeMode_MERGE_MODE_SQUASH; got != 2 {
86 t.Errorf("MERGE_MODE_SQUASH = %d, want 2", got)
87 }
88 if got := ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED; got != 3 {
89 t.Errorf("MERGE_REQUEST_STATE_CLOSED = %d, want 3", got)
90 }
91 if got := ermite.DiffSide_DIFF_SIDE_NEW; got != 2 {
92 t.Errorf("DIFF_SIDE_NEW = %d, want 2", got)
93 }
94 enums := []struct {
95 name string
96 got int32
97 want int32
98 }{
99 {"CI_STATUS_UNSPECIFIED", int32(ermite.CiStatus_CI_STATUS_UNSPECIFIED), 0},
100 {"CI_STATUS_QUEUED", int32(ermite.CiStatus_CI_STATUS_QUEUED), 1},
101 {"CI_STATUS_IN_PROGRESS", int32(ermite.CiStatus_CI_STATUS_IN_PROGRESS), 2},
102 {"CI_STATUS_COMPLETED", int32(ermite.CiStatus_CI_STATUS_COMPLETED), 3},
103 {"CI_CONCLUSION_UNSPECIFIED", int32(ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED), 0},
104 {"CI_CONCLUSION_SUCCESS", int32(ermite.CiConclusion_CI_CONCLUSION_SUCCESS), 1},
105 {"CI_CONCLUSION_FAILURE", int32(ermite.CiConclusion_CI_CONCLUSION_FAILURE), 2},
106 {"CI_CONCLUSION_CANCELLED", int32(ermite.CiConclusion_CI_CONCLUSION_CANCELLED), 3},
107 {"CI_CONCLUSION_SKIPPED", int32(ermite.CiConclusion_CI_CONCLUSION_SKIPPED), 4},
108 {"CI_CONCLUSION_TIMED_OUT", int32(ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT), 5},
109 {"CI_CONCLUSION_NEUTRAL", int32(ermite.CiConclusion_CI_CONCLUSION_NEUTRAL), 6},
110 {"CI_CONCLUSION_ACTION_REQUIRED", int32(ermite.CiConclusion_CI_CONCLUSION_ACTION_REQUIRED), 7},
111 {"STEP_STATE_UNSPECIFIED", int32(ermite.StepState_STEP_STATE_UNSPECIFIED), 0},
112 {"STEP_STATE_PENDING", int32(ermite.StepState_STEP_STATE_PENDING), 1},
113 {"STEP_STATE_IN_PROGRESS", int32(ermite.StepState_STEP_STATE_IN_PROGRESS), 2},
114 {"STEP_STATE_COMPLETED", int32(ermite.StepState_STEP_STATE_COMPLETED), 3},
115 {"RUNNER_ISOLATION_UNSPECIFIED", int32(ermite.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED), 0},
116 {"RUNNER_ISOLATION_VM", int32(ermite.RunnerIsolation_RUNNER_ISOLATION_VM), 1},
117 {"RUNNER_ISOLATION_CONTAINER_SYSBOX", int32(ermite.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_SYSBOX), 2},
118 {"RUNNER_ISOLATION_CONTAINER_PRIVILEGED", int32(ermite.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_PRIVILEGED), 3},
119 {"CANCEL_REASON_UNSPECIFIED", int32(ermite.CancelReason_CANCEL_REASON_UNSPECIFIED), 0},
120 {"CANCEL_REASON_USER", int32(ermite.CancelReason_CANCEL_REASON_USER), 1},
121 {"CANCEL_REASON_FAIL_FAST", int32(ermite.CancelReason_CANCEL_REASON_FAIL_FAST), 2},
122 {"CANCEL_REASON_TIMEOUT", int32(ermite.CancelReason_CANCEL_REASON_TIMEOUT), 3},
123 }
124 for _, e := range enums {
125 if e.got != e.want {
126 t.Errorf("%s = %d, want %d", e.name, e.got, e.want)
127 }
128 }
129}
130
131// TestEventRoundTrip checks that an event with a oneof kind survives the wire.
132func TestEventRoundTrip(t *testing.T) {
133 in := &ermite.WatchResponse{Event: &ermite.Event{
134 Id: "42",
135 Kind: &ermite.Event_Pushed{Pushed: &ermite.Pushed{
136 Repo: "ermite",
137 RefUpdates: []*ermite.RefUpdate{
138 {Ref: "refs/heads/main", OldId: "a", NewId: "b"},
139 },
140 }},
141 }}
142 b, err := proto.Marshal(in)
143 if err != nil {
144 t.Fatal(err)
145 }
146 var out ermite.WatchResponse
147 if err := proto.Unmarshal(b, &out); err != nil {
148 t.Fatal(err)
149 }
150 if !proto.Equal(in, &out) {
151 t.Errorf("round trip mismatch: %v != %v", &out, in)
152 }
153 if out.GetEvent().GetPushed().GetRefUpdates()[0].GetNewId() != "b" {
154 t.Errorf("unexpected ref update: %v", &out)
155 }
156}
157
158// TestUpdateRepoMask checks that the request carries a field mask.
159func TestUpdateRepoMask(t *testing.T) {
160 req := &ermite.UpdateRepoRequest{
161 Repo: "ermite",
162 Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
163 UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
164 }
165 if got := req.GetUpdateMask().GetPaths(); len(got) != 1 || got[0] != "visibility" {
166 t.Errorf("paths = %v", got)
167 }
168}
169
170// TestMergeRequestEventRoundTrip checks the merge request event kinds and the
171// repository settings survive the wire.
172func TestMergeRequestEventRoundTrip(t *testing.T) {
173 in := &ermite.WatchResponse{Event: &ermite.Event{
174 Id: "43",
175 Kind: &ermite.Event_CommentAdded{CommentAdded: &ermite.CommentAdded{
176 Repo: "ermite",
177 Number: 7,
178 Comment: &ermite.Comment{
179 Id: "c1", Path: "a.go", Line: 3, Side: ermite.DiffSide_DIFF_SIDE_OLD, Text: "hi",
180 },
181 }},
182 }}
183 b, err := proto.Marshal(in)
184 if err != nil {
185 t.Fatal(err)
186 }
187 var out ermite.WatchResponse
188 if err := proto.Unmarshal(b, &out); err != nil {
189 t.Fatal(err)
190 }
191 if !proto.Equal(in, &out) {
192 t.Errorf("round trip mismatch: %v != %v", &out, in)
193 }
194 if got := out.GetEvent().GetCommentAdded().GetNumber(); got != 7 {
195 t.Errorf("number = %d, want 7", got)
196 }
197
198 req := &ermite.UpdateRepoRequest{
199 Repo: "ermite",
200 Settings: &ermite.RepoSettings{
201 AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH},
202 FailingChecksBlock: true,
203 },
204 UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes", "settings.failing_checks_block"}},
205 }
206 if got := req.GetSettings().GetAllowedMergeModes(); len(got) != 1 || got[0] != ermite.MergeMode_MERGE_MODE_SQUASH {
207 t.Errorf("modes = %v", got)
208 }
209}
210
211// TestCIEventRoundTrip checks that the CI event kinds keep their field numbers
212// and survive the wire.
213func TestCIEventRoundTrip(t *testing.T) {
214 in := &ermite.WatchResponse{Event: &ermite.Event{
215 Id: "44",
216 Kind: &ermite.Event_JobUpdated{JobUpdated: &ermite.JobUpdated{Job: &ermite.RunJob{
217 Id: "j1",
218 RunId: 9,
219 Attempt: 2,
220 Status: ermite.CiStatus_CI_STATUS_COMPLETED,
221 Conclusion: ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT,
222 Isolation: ermite.RunnerIsolation_RUNNER_ISOLATION_VM,
223 Steps: []*ermite.Step{{Number: 1, Name: "Set up job", LogStart: 0, LogEnd: 3}},
224 }}},
225 }}
226 b, err := proto.Marshal(in)
227 if err != nil {
228 t.Fatal(err)
229 }
230 var out ermite.WatchResponse
231 if err := proto.Unmarshal(b, &out); err != nil {
232 t.Fatal(err)
233 }
234 if !proto.Equal(in, &out) {
235 t.Errorf("round trip mismatch: %v != %v", &out, in)
236 }
237 f := (&ermite.Event{}).ProtoReflect().Descriptor().Fields()
238 for name, num := range map[string]int{"run_updated": 12, "job_updated": 13} {
239 if fd := f.ByName(protoreflect.Name(name)); fd == nil || int(fd.Number()) != num {
240 t.Errorf("event field %s: %v, want number %d", name, fd, num)
241 }
242 }
243}
244
245// TestJobRoundTrip checks the job handed to a runner survives the wire.
246func TestJobRoundTrip(t *testing.T) {
247 in := &ermite.FetchJobResponse{Job: &ermite.Job{
248 JobId: "j1", RunId: 9, Attempt: 1, JobToken: "tok", WorkflowPath: ".github/workflows/ci.yml",
249 WorkflowYaml: "on: push", JobKey: "build", Matrix: "{}", Contexts: "{}",
250 Labels: []string{"self-hosted"}, TimeoutMinutes: 360, Repo: "ermite",
251 Sha: "0123456789012345678901234567890123456789", Ref: "refs/heads/main",
252 }}
253 b, err := proto.Marshal(in)
254 if err != nil {
255 t.Fatal(err)
256 }
257 var out ermite.FetchJobResponse
258 if err := proto.Unmarshal(b, &out); err != nil {
259 t.Fatal(err)
260 }
261 if !proto.Equal(in, &out) {
262 t.Errorf("round trip mismatch: %v != %v", &out, in)
263 }
264}
265
266// TestWatchJobRoundTrip checks the three kinds of WatchJob message and the
267// JobEnded detail.
268func TestWatchJobRoundTrip(t *testing.T) {
269 msgs := []*ermite.WatchJobResponse{
270 {Msg: &ermite.WatchJobResponse_Keepalive{Keepalive: &ermite.Keepalive{}}},
271 {Msg: &ermite.WatchJobResponse_Cancel{Cancel: &ermite.CancelRequest{
272 Reason: ermite.CancelReason_CANCEL_REASON_FAIL_FAST, Message: "m",
273 }}},
274 {Msg: &ermite.WatchJobResponse_Ended{Ended: &ermite.JobEnded{
275 Conclusion: ermite.CiConclusion_CI_CONCLUSION_CANCELLED, Error: "e",
276 }}},
277 }
278 for _, in := range msgs {
279 b, err := proto.Marshal(in)
280 if err != nil {
281 t.Fatal(err)
282 }
283 var out ermite.WatchJobResponse
284 if err := proto.Unmarshal(b, &out); err != nil {
285 t.Fatal(err)
286 }
287 if !proto.Equal(in, &out) {
288 t.Errorf("round trip mismatch: %v != %v", &out, in)
289 }
290 }
291 // The detail travels in a Connect error.
292 d, err := connect.NewErrorDetail(&ermite.JobEnded{Conclusion: ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT})
293 if err != nil {
294 t.Fatal(err)
295 }
296 cerr := connect.NewError(connect.CodeFailedPrecondition, errors.New("job ended"))
297 cerr.AddDetail(d)
298 v, err := cerr.Details()[0].Value()
299 if err != nil {
300 t.Fatal(err)
301 }
302 if got := v.(*ermite.JobEnded).GetConclusion(); got != ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT {
303 t.Errorf("detail conclusion = %v", got)
304 }
305}
306
307// TestCompleteJobRoundTrip checks the final report of a job and its answer.
308func TestCompleteJobRoundTrip(t *testing.T) {
309 in := &ermite.CompleteJobRequest{
310 JobId: "j1",
311 Conclusion: ermite.CiConclusion_CI_CONCLUSION_CANCELLED,
312 Outputs: map[string]string{"a": "b"},
313 RunnerShutdown: true,
314 Steps: []*ermite.StepUpdate{{Number: 2, State: ermite.StepState_STEP_STATE_COMPLETED}},
315 }
316 b, err := proto.Marshal(in)
317 if err != nil {
318 t.Fatal(err)
319 }
320 var out ermite.CompleteJobRequest
321 if err := proto.Unmarshal(b, &out); err != nil {
322 t.Fatal(err)
323 }
324 if !proto.Equal(in, &out) {
325 t.Errorf("round trip mismatch: %v != %v", &out, in)
326 }
327 r := &ermite.CompleteJobResponse{Applied: false, Conclusion: ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT}
328 if r.GetApplied() || r.GetConclusion() != ermite.CiConclusion_CI_CONCLUSION_TIMED_OUT {
329 t.Errorf("unexpected response %v", r)
330 }
331}
332
333// TestRunNameField pins the number of Run.name, the GitHub name of the run
334// that sits next to workflow_name, and that it survives the wire.
335func TestRunNameField(t *testing.T) {
336 fd := (&ermite.Run{}).ProtoReflect().Descriptor().Fields().ByName("name")
337 if fd == nil || fd.Number() != 19 {
338 t.Fatalf("Run.name = %v, want field 19", fd)
339 }
340 in := &ermite.Run{WorkflowName: "Names Workflow", Name: "run push on branch by someone", Title: "run push on branch by someone"}
341 b, err := proto.Marshal(in)
342 if err != nil {
343 t.Fatal(err)
344 }
345 var out ermite.Run
346 if err := proto.Unmarshal(b, &out); err != nil {
347 t.Fatal(err)
348 }
349 if out.GetName() != in.GetName() || out.GetWorkflowName() != "Names Workflow" {
350 t.Errorf("round trip: name %q, workflow_name %q", out.GetName(), out.GetWorkflowName())
351 }
352}