1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "archive/tar"
7 "bytes"
8 "compress/gzip"
9 "context"
10 "encoding/base64"
11 "encoding/json"
12 "io"
13 "net/http"
14 "net/http/httptest"
15 "path/filepath"
16 "slices"
17 "strings"
18 "sync"
19 "sync/atomic"
20 "testing"
21 "time"
22
23 "connectrpc.com/connect"
24 "github.com/stretchr/testify/assert"
25 "github.com/stretchr/testify/mock"
26 "github.com/stretchr/testify/require"
27
28 pb "bnema.dev/ermite-api/gen/ermite"
29 "bnema.dev/ermite/internal/domain"
30 "bnema.dev/ermite/internal/ports"
31 "bnema.dev/ermite/internal/ports/mocks"
32 "bnema.dev/ermite/internal/usecase"
33)
34
35// CI-2 execution (phase E1): the job repository token, the git URLs of the
36// owner, the REST subset and the resolution of actions.
37
38// fakeRemote stands in for github.com: no test reaches the network. It is the
39// generated mock of ports.GitRemote answering from a table the test edits.
40type fakeRemote struct {
41 m *mocks.GitRemote
42 mu sync.Mutex
43 refs map[string][]ports.RemoteRef
44 err map[string]error
45 calls []string
46}
47
48func newFakeRemote(t *testing.T) *fakeRemote {
49 t.Helper()
50 f := &fakeRemote{m: mocks.NewGitRemote(t), refs: map[string][]ports.RemoteRef{}, err: map[string]error{}}
51 f.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
52 func(_ context.Context, owner, repo string) ([]ports.RemoteRef, error) {
53 f.mu.Lock()
54 defer f.mu.Unlock()
55 key := strings.ToLower(owner + "/" + repo)
56 f.calls = append(f.calls, key)
57 if err := f.err[key]; err != nil {
58 return nil, err
59 }
60 refs, ok := f.refs[key]
61 if !ok {
62 return nil, domain.ErrNotFound
63 }
64 return refs, nil
65 }).Maybe()
66 return f
67}
68
69func (f *fakeRemote) set(repo string, refs ...ports.RemoteRef) {
70 f.mu.Lock()
71 defer f.mu.Unlock()
72 f.refs[repo] = refs
73}
74
75func (f *fakeRemote) asked(key string) bool {
76 f.mu.Lock()
77 defer f.mu.Unlock()
78 return slices.Contains(f.calls, key)
79}
80
81func (f *fakeRemote) count() int {
82 f.mu.Lock()
83 defer f.mu.Unlock()
84 return len(f.calls)
85}
86
87// newExecHarness is newCIHarness with a fake github.com.
88func newExecHarness(t *testing.T, remote *fakeRemote, tweak func(*usecase.CIConfig)) *ciHarness {
89 t.Helper()
90 h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci, o.remoteLister = tweak, remote.m })
91 c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
92 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
93 require.NoError(t, err)
94 c.wt = h.workTree("main")
95 return c
96}
97
98// startJob pushes a workflow and returns the job given to a runner.
99func (c *ciHarness) startJob(runner *fakeRunner) *pb.Job {
100 c.t.Helper()
101 c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0], "README": "hello\n"}, "")
102 return runner.await()
103}
104
105func (c *ciHarness) heartbeat(runner *fakeRunner, job *pb.Job) {
106 c.t.Helper()
107 _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
108 require.NoError(c.t, err)
109}
110
111// checkoutHeader is what actions/checkout sets in http.extraheader.
112func checkoutHeader(token string) string {
113 return "AUTHORIZATION: basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))
114}
115
116// clone clones url as actions/checkout does: the token in http.extraheader.
117func (c *ciHarness) clone(token, url string, args ...string) (string, error) {
118 dest := filepath.Join(c.t.TempDir(), "w")
119 a := []string{}
120 if token != "" {
121 a = append(a, "-c", "http.extraheader="+checkoutHeader(token))
122 }
123 a = append(a, "clone", "-q")
124 a = append(a, args...)
125 a = append(a, url, dest)
126 return c.git(c.t.TempDir(), a...)
127}
128
129func (c *ciHarness) get(path, authorization string) (int, string, http.Header) {
130 c.t.Helper()
131 req, err := http.NewRequest(http.MethodGet, c.url(path), nil)
132 require.NoError(c.t, err)
133 if authorization != "" {
134 req.Header.Set("Authorization", authorization)
135 }
136 resp, err := http.DefaultClient.Do(req)
137 require.NoError(c.t, err)
138 defer func() { _ = resp.Body.Close() }()
139 b, err := io.ReadAll(resp.Body)
140 require.NoError(c.t, err)
141 return resp.StatusCode, string(b), resp.Header
142}
143
144func basic(token string) string {
145 return "Basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))
146}
147
148// AC3: the job gets a repository token at its assignment, in the Job and not in
149// the contexts; it clones its repository, public or private, over the URL
150// github.repository gives, with and without .git, the way checkout does.
151func TestJobTokenClonesTheRepositoryOfTheRun(t *testing.T) {
152 c := newExecHarness(t, newFakeRemote(t), nil)
153 runner := c.registerRunner("alpha", "ubuntu-latest")
154 job := c.startJob(runner)
155 tok := job.GetGithubToken()
156 require.True(t, domain.IsRepoToken(tok), "ermitegh_ and 43 base64url characters")
157 assert.NotEqual(t, job.GetJobToken(), tok)
158 assert.NotContains(t, job.GetContexts(), tok, "the token does not sit in the contexts the runner stores")
159 assert.NotContains(t, job.GetWorkflowYaml(), tok)
160 c.heartbeat(runner, job)
161
162 // Private: checkout's clone, shallow, without .git.
163 out, err := c.clone(tok, c.url("/owner/proj"), "--depth", "1")
164 require.NoError(t, err, out)
165 out, err = c.clone(tok, c.url("/owner/proj.git"))
166 require.NoError(t, err, out)
167 out, err = c.clone(tok, c.url("/proj.git"))
168 require.NoError(t, err, "the plain URL works too: "+out)
169 // Without a token a private repository asks for credentials.
170 _, err = c.clone("", c.url("/owner/proj"))
171 require.Error(t, err)
172 // Another owner has no repositories.
173 out, err = c.clone(tok, c.url("/someone/proj"))
174 require.Error(t, err, out)
175
176 // The same token and the other schemes, on the wire.
177 for _, h := range []string{basic(tok), "Bearer " + tok, "token " + tok} {
178 status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", h)
179 assert.Equal(t, http.StatusOK, status, h[:12])
180 }
181
182 // Public: anyone clones, the token too.
183 vis := domain.VisibilityPublic
184 _, err = c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis})
185 require.NoError(t, err)
186 out, err = c.clone("", c.url("/owner/proj"))
187 require.NoError(t, err, out)
188 out, err = c.clone(tok, c.url("/owner/proj"))
189 require.NoError(t, err, out)
190}
191
192func (c *ciHarness) h(name string) domain.RepoName { return c.name(name) }
193
194// AC3: refused on another private repository, for push, and for the REST
195// subset of another repository; never a stored token for the API.
196func TestJobTokenReadsItsRepositoryOnly(t *testing.T) {
197 c := newExecHarness(t, newFakeRemote(t), nil)
198 c.seed("secret", domain.VisibilityPrivate, "a.txt")
199 runner := c.registerRunner("alpha", "ubuntu-latest")
200 job := c.startJob(runner)
201 tok := job.GetGithubToken()
202 c.heartbeat(runner, job)
203
204 out, err := c.clone(tok, c.url("/owner/secret"))
205 require.Error(t, err)
206 assert.Contains(t, out, "not found", "the other private repository does not show itself")
207 status, _, _ := c.get("/owner/secret/info/refs?service=git-upload-pack", basic(tok))
208 assert.Equal(t, http.StatusNotFound, status)
209 status, _, _ = c.get("/-/github/api/repos/owner/secret", basic(tok))
210 assert.Equal(t, http.StatusNotFound, status)
211
212 // Push: the receive-pack advertisement and the push itself are 403.
213 status, _, _ = c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok))
214 assert.Equal(t, http.StatusForbidden, status)
215 c.mustGit(c.wt, "checkout", "-q", "-b", "x")
216 out, err = c.git(c.wt, "-c", "http.extraheader="+checkoutHeader(tok), "push", c.url("/owner/proj"), "x:refs/heads/x")
217 require.Error(t, err)
218 assert.Contains(t, out, "403")
219 assert.Empty(t, c.bareGit("proj", "branch", "--list", "x"), "nothing was pushed")
220
221 // The API takes it as the principal it is (repo:read on one repository): it
222 // lists that repository and no other, and writes nothing.
223 list, err := c.api(tok).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{}))
224 require.NoError(t, err)
225 require.Len(t, list.Msg.GetRepos(), 1)
226 assert.Equal(t, "proj", list.Msg.GetRepos()[0].GetName())
227 _, err = c.api(tok).repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
228 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
229 _, err = c.api(tok).token.CreateToken(c.ctx, connect.NewRequest(&pb.CreateTokenRequest{Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}}))
230 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
231 _, err = c.api(tok).token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{}))
232 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
233 _, err = c.api(tok).run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()}))
234 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "no ci:write")
235
236 // A made-up token of the right shape is refused, and so are wrong ones.
237 for _, bad := range []string{"ermitegh_" + strings.Repeat("A", 43), "ermitegh_short", tok + "x"} {
238 status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(bad))
239 assert.Equal(t, http.StatusUnauthorized, status, bad)
240 }
241}
242
243// The token is not a stored token: ListTokens does not show it, token:admin
244// does not manage it, and the principal it gives is read-only on one repository.
245func TestJobTokenIsNotAStoredToken(t *testing.T) {
246 c := newExecHarness(t, newFakeRemote(t), nil)
247 runner := c.registerRunner("alpha", "ubuntu-latest")
248 job := c.startJob(runner)
249 tok := job.GetGithubToken()
250
251 before, err := c.root.token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{}))
252 require.NoError(t, err)
253 assert.Empty(t, before.Msg.GetTokens(), "no stored token exists")
254
255 p, err := c.forge.Auth.Authenticate(c.ctx, tok)
256 require.NoError(t, err)
257 assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, p.Scopes())
258 assert.Equal(t, []domain.RepoName{c.h("proj")}, p.Repos())
259 assert.False(t, p.ExpiresAt().IsZero())
260 assert.False(t, p.HasScope(domain.ScopeTokenAdmin))
261 assert.False(t, p.IsRoot())
262
263 // Revoking by its id (the "job:" id the principal shows) finds nothing.
264 _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: strings.TrimPrefix(p.TokenID(), "job:")}))
265 assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
266 _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: p.TokenID()}))
267 assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
268 _, err = c.forge.Auth.Authenticate(c.ctx, tok)
269 require.NoError(t, err, "still valid")
270 require.NoError(t, c.forge.Auth.Revalidate(c.ctx, p))
271}
272
273// AC3: refused after the job ends, however it ends.
274func TestJobTokenEndsWithTheJob(t *testing.T) {
275 cloneStatus := func(c *ciHarness, tok string) int {
276 status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok))
277 return status
278 }
279
280 t.Run("CompleteJob", func(t *testing.T) {
281 c := newExecHarness(t, newFakeRemote(t), nil)
282 runner := c.registerRunner("alpha", "ubuntu-latest")
283 job := c.startJob(runner)
284 tok := job.GetGithubToken()
285 p, err := c.forge.Auth.Authenticate(c.ctx, tok)
286 require.NoError(t, err)
287 assert.Equal(t, http.StatusOK, cloneStatus(c, tok))
288 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
289 assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok))
290 assert.ErrorIs(t, c.forge.Auth.Revalidate(c.ctx, p), domain.ErrUnauthenticated, "a long operation stops")
291 out, err := c.clone(tok, c.url("/owner/proj"))
292 require.Error(t, err, out)
293 })
294
295 t.Run("cancel", func(t *testing.T) {
296 c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
297 cfg.CancelGrace, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
298 })
299 runner := c.registerRunner("alpha", "ubuntu-latest")
300 job := c.startJob(runner)
301 c.heartbeat(runner, job)
302 tok := job.GetGithubToken()
303 assert.Equal(t, http.StatusOK, cloneStatus(c, tok))
304 _, err := c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()}))
305 require.NoError(t, err)
306 // The runner is told to stop, and the job concludes when it answers or
307 // when the grace ends: either way the token goes with it.
308 c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized })
309 assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, c.runs()[0].GetStatus())
310 })
311
312 t.Run("lost runner", func(t *testing.T) {
313 c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
314 cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
315 })
316 runner := c.registerRunner("alpha", "ubuntu-latest")
317 job := c.startJob(runner)
318 c.heartbeat(runner, job)
319 tok := job.GetGithubToken()
320 assert.Equal(t, http.StatusOK, cloneStatus(c, tok))
321 c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized })
322 assert.Contains(t, c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError(), "lost communication")
323 })
324
325 t.Run("assignment not acknowledged", func(t *testing.T) {
326 c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
327 cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 200*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
328 })
329 runner := c.registerRunner("alpha", "ubuntu-latest")
330 job := c.startJob(runner)
331 tok := job.GetGithubToken()
332 c.waitFor("the assignment is taken back", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized })
333 again := runner.await()
334 assert.NotEqual(t, tok, again.GetGithubToken(), "the next assignment has its own token")
335 assert.Equal(t, http.StatusOK, cloneStatus(c, again.GetGithubToken()))
336 assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok), "the old one stays revoked")
337 })
338
339 t.Run("repository deleted", func(t *testing.T) {
340 c := newExecHarness(t, newFakeRemote(t), nil)
341 runner := c.registerRunner("alpha", "ubuntu-latest")
342 job := c.startJob(runner)
343 tok := job.GetGithubToken()
344 _, err := c.forge.Auth.Authenticate(c.ctx, tok)
345 require.NoError(t, err)
346 require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("proj")))
347 _, err = c.forge.Auth.Authenticate(c.ctx, tok)
348 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the repository, its runs and their tokens are gone")
349 assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok))
350 })
351
352 t.Run("expiry", func(t *testing.T) {
353 // The deadline of the job plus five minutes: a job of one minute.
354 c := newExecHarness(t, newFakeRemote(t), nil)
355 runner := c.registerRunner("alpha", "ubuntu-latest")
356 c.push("main", map[string]string{".github/workflows/t.yml": "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n steps:\n - run: echo\n"}, "")
357 job := runner.await()
358 p, err := c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken())
359 require.NoError(t, err)
360 want := time.Now().Add(time.Minute + domain.CITimeoutSlack + domain.RepoTokenGrace)
361 assert.WithinDuration(t, want, p.ExpiresAt(), 5*time.Second)
362 })
363}
364
365// The retry of a poll gets the token of the first call, and a job token of the
366// protocol never doubles as the repository token.
367func TestJobTokenSurvivesAPollRetry(t *testing.T) {
368 c := newExecHarness(t, newFakeRemote(t), nil)
369 runner := c.registerRunner("alpha", "ubuntu-latest")
370 c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
371 var first *pb.Job
372 c.waitFor("a job", func() bool {
373 j, err := runner.fetch("poll-1")
374 require.NoError(t, err)
375 first = j
376 return j != nil
377 })
378 again, err := runner.fetch("poll-1")
379 require.NoError(t, err)
380 require.NotNil(t, again)
381 assert.Equal(t, first.GetJobToken(), again.GetJobToken())
382 assert.Equal(t, first.GetGithubToken(), again.GetGithubToken())
383 status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(first.GetJobToken()))
384 assert.Equal(t, http.StatusUnauthorized, status, "the runner's job token reads nothing")
385}
386
387// The REST subset: GitHub's shapes for what checkout and github-script call,
388// GitHub's 404 for everything else.
389func TestGitHubRESTSubset(t *testing.T) {
390 c := newExecHarness(t, newFakeRemote(t), nil)
391 runner := c.registerRunner("alpha", "ubuntu-latest")
392 job := c.startJob(runner)
393 tok := job.GetGithubToken()
394 sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
395 notFound := `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}`
396
397 // Private: the token reads it, anonymous sees nothing.
398 status, body, hdr := c.get("/-/github/api/repos/owner/proj", basic(tok))
399 require.Equal(t, http.StatusOK, status, body)
400 assert.Contains(t, hdr.Get("Content-Type"), "application/json")
401 var repo map[string]any
402 require.NoError(t, json.Unmarshal([]byte(body), &repo))
403 assert.Equal(t, "proj", repo["name"])
404 assert.Equal(t, "owner/proj", repo["full_name"])
405 assert.Equal(t, true, repo["private"])
406 assert.Equal(t, "private", repo["visibility"])
407 assert.Equal(t, "main", repo["default_branch"])
408 assert.Equal(t, "http://example.test/owner/proj", repo["html_url"])
409 assert.Equal(t, "http://example.test/owner/proj.git", repo["clone_url"])
410 assert.Equal(t, "http://example.test/-/github/api/repos/owner/proj", repo["url"])
411 assert.NotZero(t, repo["id"])
412 assert.NotEmpty(t, repo["node_id"])
413 owner, _ := repo["owner"].(map[string]any)
414 assert.Equal(t, "owner", owner["login"])
415 assert.Equal(t, "User", owner["type"])
416 assert.Equal(t, false, repo["fork"])
417 assert.Equal(t, false, repo["archived"])
418 assert.Equal(t, false, repo["has_issues"] == nil, "present")
419 assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["created_at"], "REST dates are ISO 8601")
420 assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["pushed_at"])
421 perms, _ := repo["permissions"].(map[string]any)
422 assert.Equal(t, true, perms["pull"])
423 assert.Equal(t, false, perms["push"], "the job token cannot write")
424 assert.Equal(t, false, perms["admin"])
425 assert.NotContains(t, repo, "master_branch", "the push event has it, the REST object does not")
426
427 status, body, _ = c.get("/-/github/api/repos/owner/proj", "")
428 assert.Equal(t, http.StatusNotFound, status, "a private repository does not show itself")
429 assert.JSONEq(t, notFound, body)
430 status, _, _ = c.get("/-/github/api/repos/Owner/PROJ", basic(tok))
431 assert.Equal(t, http.StatusOK, status, "names are case-insensitive as on GitHub")
432 status, _, _ = c.get("/-/github/api/repos/other/proj", basic(tok))
433 assert.Equal(t, http.StatusNotFound, status, "another owner")
434
435 status, body, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", basic(tok))
436 assert.Equal(t, http.StatusOK, status)
437 assert.JSONEq(t, `{"hash_algorithm":"sha1"}`, body)
438 status, _, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", "")
439 assert.Equal(t, http.StatusNotFound, status)
440
441 // The tarball: a gzip tar with one top-level directory, the contents of the
442 // commit, for a branch, a tag and a full id alike.
443 c.mustGit(c.wt, "tag", "v1")
444 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
445 for _, ref := range []string{"main", "v1", sha, ""} {
446 status, body, hdr := c.get("/-/github/api/repos/owner/proj/tarball/"+ref, basic(tok))
447 require.Equal(t, http.StatusOK, status, ref)
448 assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz")
449 files := untar(t, body)
450 assert.Equal(t, "hello\n", files["owner-proj-"+sha[:7]+"/README"], ref)
451 }
452 status, body, _ = c.get("/-/github/api/repos/owner/proj/tarball/nope", basic(tok))
453 assert.Equal(t, http.StatusNotFound, status)
454 assert.JSONEq(t, notFound, body)
455 status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "")
456 assert.Equal(t, http.StatusNotFound, status, "private and anonymous")
457 status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
458 assert.Equal(t, http.StatusOK, status, "any principal that reads the repository")
459
460 // A public repository answers anonymous callers.
461 vis := domain.VisibilityPublic
462 _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis})
463 require.NoError(t, err)
464 status, body, _ = c.get("/-/github/api/repos/owner/proj", "")
465 require.Equal(t, http.StatusOK, status)
466 assert.Contains(t, body, `"visibility":"public"`)
467 assert.NotContains(t, body, `"permissions"`, "an anonymous caller has none")
468 status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "")
469 assert.Equal(t, http.StatusOK, status)
470
471 // Everything else is GitHub's 404, whatever the method.
472 for _, p := range []string{
473 "/-/github/api", "/-/github/api/", "/-/github/api/user", "/-/github/api/graphql",
474 "/-/github/api/repos/owner/proj/issues", "/-/github/api/repos/owner/proj/git/refs/heads/main",
475 "/-/github/api/repos/owner/proj/zipball/main", "/-/github/api/repos/owner/proj/actions/runs",
476 "/-/github/api/repos/owner", "/-/github/api/rate_limit", "/-/github/api/repos/owner/proj/contents/README",
477 } {
478 status, body, hdr := c.get(p, basic(tok))
479 assert.Equal(t, http.StatusNotFound, status, p)
480 assert.JSONEq(t, notFound, body, p)
481 assert.Contains(t, hdr.Get("Content-Type"), "application/json", p)
482 }
483 req, err := http.NewRequest(http.MethodPost, c.url("/-/github/api/repos/owner/proj"), strings.NewReader("{}"))
484 require.NoError(t, err)
485 resp, err := http.DefaultClient.Do(req)
486 require.NoError(t, err)
487 _ = resp.Body.Close()
488 assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
489
490 // A bad credential is refused as GitHub does.
491 status, body, _ = c.get("/-/github/api/repos/owner/proj", basic("ermitegh_"+strings.Repeat("A", 43)))
492 assert.Equal(t, http.StatusUnauthorized, status)
493 assert.Contains(t, body, "Bad credentials")
494}
495
496func untar(t *testing.T, gz string) map[string]string {
497 t.Helper()
498 zr, err := gzip.NewReader(strings.NewReader(gz))
499 require.NoError(t, err)
500 tr := tar.NewReader(zr)
501 files := map[string]string{}
502 for {
503 h, err := tr.Next()
504 if err == io.EOF {
505 break
506 }
507 require.NoError(t, err)
508 if h.Typeflag != tar.TypeReg {
509 continue
510 }
511 var b bytes.Buffer
512 _, err = io.Copy(&b, tr)
513 require.NoError(t, err)
514 files[h.Name] = b.String()
515 }
516 return files
517}
518
519func (c *ciHarness) resolve(runner *fakeRunner, job *pb.Job, uses ...string) []*pb.ResolvedAction {
520 c.t.Helper()
521 res, err := c.resolveErr(runner, job, uses...)
522 require.NoError(c.t, err)
523 return res
524}
525
526func (c *ciHarness) resolveErr(runner *fakeRunner, job *pb.Job, uses ...string) ([]*pb.ResolvedAction, error) {
527 refs := make([]*pb.ActionRef, 0, len(uses))
528 for _, u := range uses {
529 refs = append(refs, &pb.ActionRef{Uses: u})
530 }
531 res, err := runner.jobAPI(job).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: job.GetJobId(), Refs: refs}))
532 if err != nil {
533 return nil, err
534 }
535 return res.Msg.GetActions(), nil
536}
537
538const (
539 shaMain = "1111111111111111111111111111111111111111"
540 shaTag = "2222222222222222222222222222222222222222"
541 shaTagObj = "3333333333333333333333333333333333333333"
542 shaPeeled = "4444444444444444444444444444444444444444"
543 shaOther = "5555555555555555555555555555555555555555"
544)
545
546// AC2, AC4: github.com actions are resolved with ls-remote (tag before branch,
547// the peeled commit of an annotated tag, a full id as it is), ermite
548// repositories win, and nothing reaches the network but through the fake.
549func TestResolveActionsFromGitHub(t *testing.T) {
550 remote := newFakeRemote(t)
551 remote.set("actions/checkout",
552 ports.RemoteRef{Name: "refs/heads/main", ID: shaMain},
553 ports.RemoteRef{Name: "refs/tags/v4", ID: shaTagObj, Peeled: shaPeeled},
554 ports.RemoteRef{Name: "refs/tags/v3", ID: shaTag},
555 ports.RemoteRef{Name: "refs/heads/v3", ID: shaOther}, // a tag and a branch of the same name: the tag
556 )
557 c := newExecHarness(t, remote, nil)
558 runner := c.registerRunner("alpha", "ubuntu-latest")
559 job := c.startJob(runner)
560 c.heartbeat(runner, job)
561
562 got := c.resolve(runner, job,
563 "actions/checkout@v4", "actions/checkout@main", "actions/checkout@v3", "Actions/Checkout/sub/dir@v4",
564 "actions/checkout@"+strings.ToUpper(shaOther), "nobody/unknown-action@"+shaOther, "actions/checkout@v4",
565 )
566 require.Len(t, got, 7)
567 assert.Equal(t, "actions/checkout@v4", got[0].GetRef())
568 assert.Equal(t, "actions/checkout", got[0].GetNameWithOwner())
569 assert.Equal(t, shaPeeled, got[0].GetResolvedSha(), "an annotated tag gives its commit")
570 assert.Equal(t, "https://codeload.github.com/actions/checkout/tar.gz/"+shaPeeled, got[0].GetTarballUrl())
571 assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, got[0].GetSource())
572 assert.Empty(t, got[0].GetToken(), "no token goes to github.com")
573 assert.Empty(t, got[0].GetError())
574 assert.Equal(t, shaMain, got[1].GetResolvedSha(), "a branch")
575 assert.Equal(t, shaTag, got[2].GetResolvedSha(), "a tag wins over a branch of the same name")
576 assert.Equal(t, "Actions/Checkout/sub/dir@v4", got[3].GetRef(), "the reference is returned as written")
577 assert.Equal(t, "Actions/Checkout", got[3].GetNameWithOwner())
578 assert.Equal(t, "sub/dir", got[3].GetPath())
579 assert.Equal(t, shaPeeled, got[3].GetResolvedSha(), "owner and repository are case-insensitive")
580 assert.Equal(t, strings.ToLower(shaOther), got[4].GetResolvedSha(), "a full commit id is not checked")
581 assert.Equal(t, strings.ToLower(shaOther), got[5].GetResolvedSha(), "not even for a repository ls-remote does not know")
582 assert.Equal(t, got[0].GetResolvedSha(), got[6].GetResolvedSha(), "duplicates are answered one by one")
583 assert.Equal(t, 1, remote.count(), "one ls-remote per repository and call")
584
585 // GitHub's words for what cannot be resolved; the call itself succeeds.
586 bad := c.resolve(runner, job, "actions/checkout@nope", "nobody/missing@v1", "noslash@v1", "a/b", "a/b@", "a/b@c@d")
587 assert.Equal(t, "Unable to resolve action `actions/checkout@nope`, unable to find version `nope`", bad[0].GetError())
588 assert.Equal(t, "Unable to resolve action `nobody/missing@v1`, repository not found", bad[1].GetError())
589 assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'", bad[2].GetError())
590 assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'a/b'", bad[3].GetError())
591 assert.NotEmpty(t, bad[4].GetError())
592 assert.NotEmpty(t, bad[5].GetError())
593 for _, b := range bad {
594 assert.Empty(t, b.GetResolvedSha(), b.GetRef())
595 assert.Empty(t, b.GetTarballUrl(), b.GetRef())
596 assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, b.GetSource(), b.GetRef())
597 }
598
599 // github.com unreachable is an error of the call, not of one action.
600 remote.err["flaky/action"] = domain.ErrUnavailable
601 _, err := c.resolveErr(runner, job, "flaky/action@v1")
602 assert.Equal(t, connect.CodeUnavailable, ciErrCode(err))
603}
604
605func TestResolveActionsRejectsWhatTheRunnerHandles(t *testing.T) {
606 c := newExecHarness(t, newFakeRemote(t), nil)
607 runner := c.registerRunner("alpha", "ubuntu-latest")
608 job := c.startJob(runner)
609 c.heartbeat(runner, job)
610 for _, u := range []string{"./local", "./.github/actions/x", "docker://alpine:3", "actions/checkout@v4"} {
611 _, err := c.resolveErr(runner, job, "actions/checkout@"+shaMain, u)
612 if u == "actions/checkout@v4" {
613 assert.NoError(t, err)
614 continue
615 }
616 assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), u)
617 }
618 many := make([]string, 101)
619 for i := range many {
620 many[i] = "a/b@" + shaMain
621 }
622 _, err := c.resolveErr(runner, job, many...)
623 assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "at most 100")
624 many = many[:100]
625 got, err := c.resolveErr(runner, job, many...)
626 require.NoError(t, err)
627 assert.Len(t, got, 100)
628 got, err = c.resolveErr(runner, job)
629 require.NoError(t, err)
630 assert.Empty(t, got)
631}
632
633// AC2: another job's token, a repository token, an ended job and a bad token
634// are refused.
635func TestResolveActionsAuthentication(t *testing.T) {
636 c := newExecHarness(t, newFakeRemote(t), nil)
637 runner := c.registerRunner("alpha", "ubuntu-latest")
638 job := c.startJob(runner)
639 c.heartbeat(runner, job)
640 call := func(secret, id string) error {
641 _, err := c.api(secret).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: id, Refs: []*pb.ActionRef{{Uses: "a/b@" + shaMain}}}))
642 return err
643 }
644 assert.NoError(t, call(job.GetJobToken(), job.GetJobId()))
645 assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(job.GetGithubToken(), job.GetJobId())), "the repository token is not a job token")
646 assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(runner.secret, job.GetJobId())), "nor the runner token")
647 assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("ermitejob_"+strings.Repeat("A", 43), job.GetJobId())))
648 assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("", job.GetJobId())))
649 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(call(job.GetJobToken(), "7-0-0")), "a token of another job")
650
651 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
652 err := call(job.GetJobToken(), job.GetJobId())
653 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "an ended job")
654 var ce *connect.Error
655 require.ErrorAs(t, err, &ce)
656 assert.NotEmpty(t, ce.Details(), "with a JobEnded detail")
657}
658
659// AC4: an action of a repository of ermite wins over github.com, is downloaded
660// with a per-job token bound to that commit of that repository, and the same ref gives the
661// same commit to every job of the attempt (and a new one to a re-run).
662func TestResolveActionsFromErmite(t *testing.T) {
663 remote := newFakeRemote(t)
664 remote.set("owner/myaction", ptrRef("refs/heads/main", shaMain)) // shadowed by ermite
665 c := newExecHarness(t, remote, nil)
666 c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js")
667 tag := c.bareGit("myaction", "rev-parse", "HEAD")
668 c.mustGit(c.repoDir("myaction"), "tag", "v1")
669 c.mustGit(c.repoDir("myaction"), "tag", "-a", "-m", "annotated", "v2")
670 c.mustGit(c.repoDir("myaction"), "branch", "dev")
671 c.seed("hidden", domain.VisibilityPrivate, "secret.txt")
672
673 runner := c.registerRunner("alpha", "ubuntu-latest")
674 c.push("main", map[string]string{".github/workflows/m.yml": "on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n b:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"}, "")
675 a, b := runner.await(), runner.await()
676 c.heartbeat(runner, a)
677 c.heartbeat(runner, b)
678
679 got := c.resolve(runner, a, "owner/myaction@v1", "owner/MyAction/sub@v2", "owner/myaction@dev", "owner/myaction@"+tag, "owner/myaction@gone", "owner/nothere@v1")
680 require.Len(t, got, 6)
681 for i := range 4 {
682 assert.Empty(t, got[i].GetError(), got[i].GetRef())
683 assert.Equal(t, tag, got[i].GetResolvedSha(), got[i].GetRef())
684 assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[i].GetSource(), got[i].GetRef())
685 assert.True(t, domain.IsRepoToken(got[i].GetToken()), got[i].GetRef())
686 assert.Equal(t, "http://example.test/-/github/api/repos/owner/myaction/tarball/"+tag, got[i].GetTarballUrl())
687 }
688 assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner())
689 assert.Equal(t, "sub", got[1].GetPath())
690 assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per commit and job")
691 assert.Contains(t, got[4].GetError(), "unable to find version `gone`")
692 assert.False(t, remote.asked("owner/myaction"), "ermite is asked first, github.com not at all")
693 assert.Contains(t, got[5].GetError(), "Unable to resolve action `owner/nothere@v1`")
694 assert.True(t, remote.asked("owner/nothere"), "an owner repository that ermite lacks falls through to github.com")
695
696 // The archive token downloads that repository, whatever the private
697 // repository of the run's token reaches, and nothing else.
698 arch := got[0].GetToken()
699 status, body, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch))
700 require.Equal(t, http.StatusOK, status)
701 assert.Equal(t, "content of dist/index.js\n", untar(t, body)["owner-myaction-"+tag[:7]+"/dist/index.js"])
702 status, _, _ = c.get("/owner/myaction/info/refs?service=git-upload-pack", basic(arch))
703 assert.Equal(t, http.StatusNotFound, status, "it does not clone: the archive of one commit is all it gets")
704 status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch))
705 assert.Equal(t, http.StatusNotFound, status, "and never writes")
706 status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch))
707 assert.Equal(t, http.StatusNotFound, status, "another private repository")
708 status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch))
709 assert.Equal(t, http.StatusNotFound, status, "not even the repository of the run")
710 status, _, _ = c.get("/-/github/api/repos/owner/hidden", basic(arch))
711 assert.Equal(t, http.StatusNotFound, status)
712 // The job's own token does not read the action repository: this is why the
713 // archive has a token of its own.
714 status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(a.GetGithubToken()))
715 assert.Equal(t, http.StatusNotFound, status)
716
717 // Per job: the same commit, another token.
718 other := c.resolve(runner, b, "owner/myaction@v1")
719 assert.Equal(t, tag, other[0].GetResolvedSha())
720 assert.NotEqual(t, arch, other[0].GetToken(), "tokens are not shared between jobs")
721
722 // Stable for the attempt: the branch moves, the answer does not.
723 c.mustGit(c.wt, "checkout", "-q", "-b", "scratch")
724 c.writeFile(c.wt, "later.txt", []byte("later"))
725 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/myaction.git"), "scratch:refs/heads/dev", "-f")
726 moved := c.bareGit("myaction", "rev-parse", "dev")
727 require.NotEqual(t, tag, moved)
728 again := c.resolve(runner, b, "owner/myaction@dev")
729 assert.Equal(t, tag, again[0].GetResolvedSha(), "the first resolution of the attempt stands")
730
731 // The token goes with its job.
732 runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
733 status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch))
734 assert.Equal(t, http.StatusUnauthorized, status, "revoked with the job")
735 status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(other[0].GetToken()))
736 assert.Equal(t, http.StatusOK, status, "the other job's token lives on")
737}
738
739func ptrRef(name, id string) ports.RemoteRef { return ports.RemoteRef{Name: name, ID: id} }
740
741// A re-run resolves again, and github.com is asked once for a repository in
742// each attempt.
743func TestResolveActionsAreCachedPerAttempt(t *testing.T) {
744 remote := newFakeRemote(t)
745 remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaMain))
746 c := newExecHarness(t, remote, nil)
747 runner := c.registerRunner("alpha", "ubuntu-latest")
748 job := c.startJob(runner)
749 c.heartbeat(runner, job)
750
751 assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha())
752 remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaOther))
753 assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha(), "stable within the attempt")
754 assert.Equal(t, 1, remote.count(), "the second call is answered from the attempt's resolution")
755 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
756
757 _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()}))
758 require.NoError(t, err)
759 job2 := runner.await()
760 assert.Equal(t, int32(2), job2.GetAttempt())
761 c.heartbeat(runner, job2)
762 assert.Equal(t, shaOther, c.resolve(runner, job2, "actions/setup-go@v5")[0].GetResolvedSha(), "a re-run resolves again")
763 assert.NotEqual(t, job.GetGithubToken(), job2.GetGithubToken())
764}
765
766// AC2: no secret reaches the logs of the server: the repository token, the
767// archive tokens and the job token, whatever happened with them.
768func TestRepositoryTokensAreNeverLogged(t *testing.T) {
769 remote := newFakeRemote(t)
770 c := newExecHarness(t, remote, nil)
771 c.seed("myaction", domain.VisibilityPrivate, "action.yml")
772 runner := c.registerRunner("alpha", "ubuntu-latest")
773 job := c.startJob(runner)
774 c.heartbeat(runner, job)
775 tok := job.GetGithubToken()
776
777 arch := c.resolve(runner, job, "owner/myaction@main")[0]
778 require.NotEmpty(t, arch.GetToken())
779 _, err := c.clone(tok, c.url("/owner/proj"))
780 require.NoError(t, err)
781 c.get("/-/github/api/repos/owner/proj", basic(tok))
782 c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+tok)
783 c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok))
784 c.get("/owner/secret/info/refs?service=git-upload-pack", basic("ermitegh_"+strings.Repeat("Z", 43))) // a wrong one
785 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
786 c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok))
787
788 logs := c.logs.String()
789 require.NotEmpty(t, logs)
790 for _, secret := range []string{tok, arch.GetToken(), job.GetJobToken(), base64.StdEncoding.EncodeToString([]byte("x-access-token:" + tok))} {
791 assert.NotContains(t, logs, secret)
792 }
793 assert.NotContains(t, logs, strings.Repeat("Z", 43))
794}
795
796// pushAs pushes a commit to branch of the repository with the token and returns
797// its sha.
798func (c *ciHarness) pushAs(token, branch string, files map[string]string) string {
799 c.t.Helper()
800 if _, err := c.git(c.wt, "checkout", "-q", branch); err != nil {
801 c.mustGit(c.wt, "checkout", "-q", "-b", branch)
802 }
803 for name, content := range files {
804 c.writeFile(c.wt, name, []byte(content))
805 }
806 c.mustGit(c.wt, "push", "-q", c.urlAuth(token, "/"+c.repo+".git"), branch)
807 return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
808}
809
810const oneJobWorkflow = ".github/workflows/ci.yml"
811
812func oneJobYAML() string { return strings.Split(ciWorkflow, " test:")[0] }
813
814// GO-001a: an action of another repository of ermite is resolved on behalf of
815// the pusher of the run, with the rule of the forge: a pusher that cannot read
816// the repository gets "repository not found", exactly as for one that does not
817// exist, and the check is made again at each answer with the live token.
818func TestResolveActionsOfAnotherRepositoryNeedsAPusherWhoReadsIt(t *testing.T) {
819 notFound := "Unable to resolve action `owner/private-action@v1`, repository not found"
820 missing := "Unable to resolve action `owner/does-not-exist@v1`, repository not found"
821
822 type scenario struct {
823 name string
824 // token makes the stored token that pushes the run, nil for the root token.
825 token func(c *ciHarness) (secret, id string)
826 reads bool
827 }
828 scenarios := []scenario{
829 {"root", nil, true},
830 {"a token without the repository in its list", func(c *ciHarness) (string, string) {
831 return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
832 }, false},
833 {"a token without repo:read", func(c *ciHarness) (string, string) {
834 return c.branchToken([]domain.Scope{domain.ScopeRepoWrite}, nil)
835 }, false},
836 {"a token that lists both repositories", func(c *ciHarness) (string, string) {
837 return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj", "private-action")
838 }, true},
839 {"a token for every repository", func(c *ciHarness) (string, string) {
840 return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil)
841 }, true},
842 }
843 for _, sc := range scenarios {
844 t.Run(sc.name, func(t *testing.T) {
845 c := newExecHarness(t, newFakeRemote(t), nil)
846 c.seed("private-action", domain.VisibilityPrivate, "action.yml")
847 secret := adminToken
848 if sc.token != nil {
849 secret, _ = sc.token(c)
850 }
851 runner := c.registerRunner("alpha", "ubuntu-latest")
852 c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
853 job := runner.await()
854 c.heartbeat(runner, job)
855
856 got := c.resolve(runner, job, "owner/private-action@v1", "owner/does-not-exist@v1")
857 assert.Equal(t, missing, got[1].GetError())
858 c.mustGit(c.repoDir("private-action"), "tag", "v1")
859 got = c.resolve(runner, job, "owner/private-action@v1")
860 if sc.reads {
861 require.Empty(t, got[0].GetError())
862 assert.True(t, domain.IsRepoToken(got[0].GetToken()))
863 assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource())
864 } else {
865 assert.Equal(t, notFound, got[0].GetError(), "as GitHub says for a repository that is not there")
866 assert.Empty(t, got[0].GetToken())
867 assert.Empty(t, got[0].GetResolvedSha())
868 assert.Empty(t, got[0].GetTarballUrl())
869 assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, got[0].GetSource())
870 }
871 // A wrong ref of a repository that cannot be read leaks nothing either:
872 // the same text as for a missing repository, not "unable to find version".
873 bad := c.resolve(runner, job, "owner/private-action@nope")
874 if sc.reads {
875 assert.Contains(t, bad[0].GetError(), "unable to find version `nope`")
876 } else {
877 assert.Equal(t, "Unable to resolve action `owner/private-action@nope`, repository not found", bad[0].GetError())
878 }
879 })
880 }
881
882 t.Run("the token is looked up again, so revoking it stops the answers", func(t *testing.T) {
883 c := newExecHarness(t, newFakeRemote(t), nil)
884 c.seed("private-action", domain.VisibilityPrivate, "action.yml")
885 c.mustGit(c.repoDir("private-action"), "tag", "v1")
886 secret, id := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil)
887 runner := c.registerRunner("alpha", "ubuntu-latest")
888 c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
889 job := runner.await()
890 c.heartbeat(runner, job)
891
892 first := c.resolve(runner, job, "owner/private-action@v1")
893 require.Empty(t, first[0].GetError())
894
895 require.NoError(t, c.forge.Tokens.Revoke(c.ctx, domain.Root(), id))
896 again := c.resolve(runner, job, "owner/private-action@v1")
897 assert.Equal(t, notFound, again[0].GetError(), "the commit is resolved already, the pusher no longer reads")
898 assert.Empty(t, again[0].GetToken())
899 })
900
901 t.Run("a repository made private since", func(t *testing.T) {
902 c := newExecHarness(t, newFakeRemote(t), nil)
903 c.seed("private-action", domain.VisibilityPublic, "action.yml")
904 c.mustGit(c.repoDir("private-action"), "tag", "v1")
905 secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
906 runner := c.registerRunner("alpha", "ubuntu-latest")
907 c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
908 job := runner.await()
909 c.heartbeat(runner, job)
910
911 assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError(), "public: anyone reads it")
912 priv := domain.VisibilityPrivate
913 _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("private-action"), ports.UpdateRepoParams{Visibility: &priv})
914 require.NoError(t, err)
915 assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError())
916 })
917
918 t.Run("an expired token", func(t *testing.T) {
919 c := newExecHarness(t, newFakeRemote(t), nil)
920 c.seed("private-action", domain.VisibilityPrivate, "action.yml")
921 c.mustGit(c.repoDir("private-action"), "tag", "v1")
922 exp := time.Now().Add(1500 * time.Millisecond)
923 got, err := c.forge.Tokens.Create(c.ctx, domain.Root(), ports.CreateTokenParams{
924 Label: "short", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, ExpiresAt: exp,
925 })
926 require.NoError(t, err)
927 runner := c.registerRunner("alpha", "ubuntu-latest")
928 c.pushAs(got.Secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
929 job := runner.await()
930 c.heartbeat(runner, job)
931 assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError())
932 time.Sleep(time.Until(exp) + 200*time.Millisecond)
933 assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError())
934 })
935
936 t.Run("the repository of the run is always readable by its job", func(t *testing.T) {
937 c := newExecHarness(t, newFakeRemote(t), nil)
938 secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
939 runner := c.registerRunner("alpha", "ubuntu-latest")
940 c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
941 job := runner.await()
942 c.heartbeat(runner, job)
943 got := c.resolve(runner, job, "owner/proj@main")
944 require.Empty(t, got[0].GetError())
945 assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource())
946 })
947}
948
949// GO-001b: the archive token is bound to (repository, commit). Only the archive
950// endpoint reads it, only for that commit; it is refused by Git over HTTP, the
951// repository and hash-algorithm endpoints, and for any other commit, ref or
952// repository, public or not.
953func TestArchiveTokenIsBoundToItsCommit(t *testing.T) {
954 c := newExecHarness(t, newFakeRemote(t), nil)
955 c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js")
956 first := c.bareGit("myaction", "rev-parse", "HEAD")
957 c.mustGit(c.repoDir("myaction"), "tag", "v1")
958 // A second commit, to have a sha the token was not issued for.
959 other := c.workTree("main", "action.yml", "dist/index.js", "more.js")
960 c.mustGit(other, "push", "-q", "-f", c.urlAuth(adminToken, "/myaction.git"), "main")
961 second := c.bareGit("myaction", "rev-parse", "HEAD")
962 require.NotEqual(t, first, second)
963 c.seed("openrepo", domain.VisibilityPublic, "a.txt")
964 openSha := c.bareGit("openrepo", "rev-parse", "HEAD")
965
966 runner := c.registerRunner("alpha", "ubuntu-latest")
967 job := c.startJob(runner)
968 c.heartbeat(runner, job)
969 got := c.resolve(runner, job, "owner/myaction@v1", "owner/myaction@"+second)
970 require.Empty(t, got[0].GetError())
971 require.Empty(t, got[1].GetError())
972 assert.Equal(t, first, got[0].GetResolvedSha())
973 assert.Equal(t, second, got[1].GetResolvedSha())
974 assert.NotEqual(t, got[0].GetToken(), got[1].GetToken(), "one token per commit")
975 arch := got[0].GetToken()
976 tarball := func(repo, ref, tok string) int {
977 status, _, _ := c.get("/-/github/api/repos/owner/"+repo+"/tarball/"+ref, basic(tok))
978 return status
979 }
980
981 // What it is for: that commit, named by its sha or by a ref that resolves to it.
982 assert.Equal(t, http.StatusOK, tarball("myaction", first, arch))
983 assert.Equal(t, http.StatusOK, tarball("myaction", "v1", arch), "any ref that resolves to the commit")
984 assert.Equal(t, http.StatusOK, tarball("MyAction", first, arch), "names are case-insensitive")
985 status, _, hdr := c.head("/-/github/api/repos/owner/myaction/tarball/"+first, basic(arch))
986 assert.Equal(t, http.StatusOK, status)
987 assert.Contains(t, hdr.Get("Content-Disposition"), "owner-myaction-"+first[:7])
988
989 // Nothing else.
990 assert.Equal(t, http.StatusNotFound, tarball("myaction", second, arch), "another commit of the repository")
991 assert.Equal(t, http.StatusNotFound, tarball("myaction", "main", arch), "a ref that now points elsewhere")
992 assert.Equal(t, http.StatusNotFound, tarball("myaction", "", arch), "the default branch")
993 assert.Equal(t, http.StatusNotFound, tarball("proj", "main", arch), "the repository of the run")
994 assert.Equal(t, http.StatusNotFound, tarball("openrepo", openSha, arch), "not even a public repository")
995 assert.Equal(t, http.StatusUnauthorized, tarball("myaction", first, arch+"x"), "a token that is not one")
996 status, _, _ = c.head("/-/github/api/repos/owner/myaction/tarball/"+second, basic(arch))
997 assert.Equal(t, http.StatusNotFound, status, "HEAD is checked like GET")
998 for _, p := range []string{
999 "/owner/myaction/info/refs?service=git-upload-pack",
1000 "/owner/myaction.git/info/refs?service=git-upload-pack",
1001 "/myaction/info/refs?service=git-upload-pack",
1002 "/owner/myaction/info/refs?service=git-receive-pack",
1003 "/owner/openrepo/info/refs?service=git-upload-pack",
1004 "/-/github/api/repos/owner/myaction",
1005 "/-/github/api/repos/owner/myaction/hash-algorithm",
1006 "/-/github/api/repos/owner/openrepo",
1007 "/-/github/api/repos/owner/openrepo/hash-algorithm",
1008 } {
1009 status, _, _ := c.get(p, basic(arch))
1010 assert.Contains(t, []int{http.StatusNotFound, http.StatusUnauthorized}, status, p)
1011 }
1012 // It lists no repository over the API either, and holds no scope there.
1013 list, err := c.api(arch).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{}))
1014 require.NoError(t, err)
1015 names := []string{}
1016 for _, r := range list.Msg.GetRepos() {
1017 names = append(names, r.GetName())
1018 }
1019 assert.Empty(t, names, "it reads nothing, not even the public repositories an anonymous caller reads")
1020 _, err = c.api(arch).repo.GetRepo(c.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "myaction"}))
1021 assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
1022
1023 // The job's own token is the opposite: it reads its repository whole, and
1024 // the archive of nothing else.
1025 own := job.GetGithubToken()
1026 assert.Equal(t, http.StatusNotFound, tarball("myaction", first, own))
1027 assert.Equal(t, http.StatusOK, tarball("proj", "main", own))
1028
1029 // Principals.
1030 p, err := c.forge.Auth.Authenticate(c.ctx, arch)
1031 require.NoError(t, err)
1032 repo, sha, ok := p.ArchiveGrant()
1033 require.True(t, ok)
1034 assert.Equal(t, "myaction", repo.String())
1035 assert.Equal(t, first, sha)
1036 assert.Empty(t, p.Scopes())
1037 assert.False(t, p.HasScope(domain.ScopeRepoRead))
1038 assert.False(t, p.Allows(domain.ScopeRepoRead, repo))
1039 assert.False(t, p.IsRoot())
1040 ownP, err := c.forge.Auth.Authenticate(c.ctx, own)
1041 require.NoError(t, err)
1042 _, _, ok = ownP.ArchiveGrant()
1043 assert.False(t, ok)
1044}
1045
1046func (c *ciHarness) head(path, authorization string) (int, string, http.Header) {
1047 c.t.Helper()
1048 req, err := http.NewRequest(http.MethodHead, c.url(path), nil)
1049 require.NoError(c.t, err)
1050 if authorization != "" {
1051 req.Header.Set("Authorization", authorization)
1052 }
1053 resp, err := http.DefaultClient.Do(req)
1054 require.NoError(c.t, err)
1055 defer func() { _ = resp.Body.Close() }()
1056 b, _ := io.ReadAll(resp.Body)
1057 return resp.StatusCode, string(b), resp.Header
1058}
1059
1060// GO-002: a HEAD of the tarball answers the headers of the archive of the ref it
1061// resolves, runs no git archive, and 404s a ref that does not resolve.
1062func TestTarballHeadRunsNoArchive(t *testing.T) {
1063 c := newExecHarness(t, newFakeRemote(t), nil)
1064 runner := c.registerRunner("alpha", "ubuntu-latest")
1065 job := c.startJob(runner)
1066 tok := job.GetGithubToken()
1067 sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
1068
1069 status, body, hdr := c.head("/-/github/api/repos/owner/proj/tarball/main", basic(tok))
1070 assert.Equal(t, http.StatusOK, status)
1071 assert.Empty(t, body, "no body")
1072 assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz")
1073 assert.Contains(t, hdr.Get("Content-Type"), "gzip")
1074 status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/nope", basic(tok))
1075 assert.Equal(t, http.StatusNotFound, status)
1076 status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/main", "")
1077 assert.Equal(t, http.StatusNotFound, status, "private and anonymous")
1078 status, _, _ = c.head("/-/github/api/repos/owner/other/tarball/main", basic(tok))
1079 assert.Equal(t, http.StatusNotFound, status)
1080}
1081
1082// GO-003: one client cannot take every archive slot, and the slots are given
1083// back.
1084func TestArchiveDownloadsAreLimitedPerClient(t *testing.T) {
1085 h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.archivesPerClient = 1 })
1086 c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
1087 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
1088 require.NoError(t, err)
1089 c.wt = h.workTree("main")
1090 c.writeFile(c.wt, "big.bin", bytes.Repeat([]byte("0123456789abcdef"), 1<<20)) // 16 MiB, compressible
1091 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main")
1092
1093 // Start a download and read none of it: the response is held open.
1094 req, err := http.NewRequest(http.MethodGet, c.url("/-/github/api/repos/owner/proj/tarball/main"), nil)
1095 require.NoError(t, err)
1096 req.Header.Set("Authorization", "Bearer "+adminToken)
1097 resp, err := http.DefaultClient.Do(req)
1098 require.NoError(t, err)
1099
1100 var status int
1101 c.waitFor("the second download is refused", func() bool {
1102 status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
1103 return status == http.StatusTooManyRequests
1104 })
1105 assert.Equal(t, http.StatusTooManyRequests, status)
1106 hs, _, _ := c.head("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
1107 assert.Equal(t, http.StatusOK, hs, "a HEAD takes no slot")
1108 metaStatus, _, _ := c.get("/-/github/api/repos/owner/proj", "Bearer "+adminToken)
1109 assert.Equal(t, http.StatusOK, metaStatus, "nor does the repository endpoint")
1110
1111 // Reading it to the end gives the slot back.
1112 _, err = io.Copy(io.Discard, resp.Body)
1113 require.NoError(t, err)
1114 _ = resp.Body.Close()
1115 c.waitFor("the slot is given back", func() bool {
1116 status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
1117 return status == http.StatusOK
1118 })
1119}
1120
1121// GO-004: owner and repository are validated with GitHub's charset for every
1122// source; nothing but a name reaches a URL, a path or a command.
1123func TestResolveActionsRejectsMalformedNames(t *testing.T) {
1124 remote := newFakeRemote(t)
1125 c := newExecHarness(t, remote, nil)
1126 runner := c.registerRunner("alpha", "ubuntu-latest")
1127 job := c.startJob(runner)
1128 c.heartbeat(runner, job)
1129
1130 bad := []string{
1131 "../..@" + shaMain, ".../x@" + shaMain, "../x@" + shaMain, "..//x@" + shaMain, "a/..@" + shaMain, "../b@" + shaMain,
1132 "a/b%2e@" + shaMain, "a%2fb/c@" + shaMain, "a/b?x=1@" + shaMain, "a/b#f@" + shaMain, "a b/c@" + shaMain,
1133 "a/b c@" + shaMain, "a:b/c@" + shaMain, "a/b\n@" + shaMain, "a@b/c@v1", "a/b\x00@v1", "a/...@v1", "a/.@v1",
1134 "é/b@v1", strings.Repeat("a", 101) + "/b@v1", "a/" + strings.Repeat("b", 101) + "@v1", "/@v1", "@@",
1135 }
1136 got := c.resolve(runner, job, bad...)
1137 require.Len(t, got, len(bad))
1138 for i, g := range got {
1139 assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual '"+bad[i]+"'", g.GetError(), "%q", bad[i])
1140 assert.Empty(t, g.GetTarballUrl(), "%q", bad[i])
1141 assert.Empty(t, g.GetToken(), "%q", bad[i])
1142 }
1143 assert.Zero(t, remote.count(), "no malformed name reached ls-remote")
1144
1145 // Names GitHub accepts still do.
1146 remote.set("a.b/c_d-e", ptrRef("refs/tags/v1", shaMain))
1147 ok := c.resolve(runner, job, "a.b/c_d-e@v1", "Owner/repo/sub@"+shaMain)
1148 assert.Empty(t, ok[0].GetError())
1149 assert.Empty(t, ok[1].GetError())
1150}
1151
1152// A token revoked, a job lost, the server restarted: the archive token goes with
1153// the job, and it was never written anywhere but hashed.
1154func TestArchiveTokenIsRevokedWithTheJobAfterARestart(t *testing.T) {
1155 c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
1156 cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 600*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
1157 })
1158 c.seed("myaction", domain.VisibilityPrivate, "action.yml")
1159 sha := c.bareGit("myaction", "rev-parse", "HEAD")
1160 runner := c.registerRunner("alpha", "ubuntu-latest")
1161 job := c.startJob(runner)
1162 c.heartbeat(runner, job)
1163 arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken()
1164 require.NotEmpty(t, arch)
1165 url := "/-/github/api/repos/owner/myaction/tarball/" + sha
1166 status, _, _ := c.get(url, basic(arch))
1167 require.Equal(t, http.StatusOK, status)
1168
1169 // Restart: the tokens are in the store, the secrets in memory are gone.
1170 cfg, hookExe := c.forge.cfg, binary(t)
1171 c.srv.Close()
1172 c.forge.Close(c.ctx)
1173 f2, err := newForge(c.ctx, cfg, options{hookExe: hookExe, remoteLister: newFakeRemote(t).m, ci: func(cc *usecase.CIConfig) {
1174 cc.HeartbeatDeadline, cc.Tick = 600*time.Millisecond, 20*time.Millisecond
1175 }})
1176 require.NoError(t, err)
1177 t.Cleanup(func() { f2.Close(context.WithoutCancel(c.ctx)) })
1178 srv := httptest.NewServer(f2.server.Handler())
1179 t.Cleanup(srv.Close)
1180
1181 get := func(tok string) int {
1182 req, _ := http.NewRequest(http.MethodGet, srv.URL+url, nil)
1183 req.Header.Set("Authorization", basic(tok))
1184 resp, err := http.DefaultClient.Do(req)
1185 require.NoError(t, err)
1186 _ = resp.Body.Close()
1187 return resp.StatusCode
1188 }
1189 assert.Equal(t, http.StatusOK, get(arch), "the token survives a restart")
1190
1191 // The runner is gone: the heartbeat deadline of the new server passes, the
1192 // job is lost and its tokens with it.
1193 require.Eventually(t, func() bool { return get(arch) == http.StatusUnauthorized }, 10*time.Second, 50*time.Millisecond)
1194 assert.Equal(t, http.StatusUnauthorized, get(job.GetGithubToken()), "the job's own token as well")
1195 api := newClients(srv.URL+"/-/api", adminToken)
1196 res, err := api.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: job.GetRunId()}))
1197 require.NoError(t, err)
1198 assert.Contains(t, res.Msg.GetJobs()[0].GetError(), "lost communication")
1199}
1200
1201// GO-006: the archive tokens that read a repository go when it is deleted, and
1202// the run that held one goes on without a stale index.
1203func TestArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) {
1204 c := newExecHarness(t, newFakeRemote(t), nil)
1205 c.seed("myaction", domain.VisibilityPrivate, "action.yml")
1206 sha := c.bareGit("myaction", "rev-parse", "HEAD")
1207 runner := c.registerRunner("alpha", "ubuntu-latest")
1208 job := c.startJob(runner)
1209 c.heartbeat(runner, job)
1210 arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken()
1211 _, err := c.forge.Auth.Authenticate(c.ctx, arch)
1212 require.NoError(t, err)
1213
1214 require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("myaction")))
1215 _, err = c.forge.Auth.Authenticate(c.ctx, arch)
1216 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "deleted with the repository it reads")
1217 _, err = c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken())
1218 assert.NoError(t, err, "the job's own token is the run's repository's, not touched")
1219
1220 // A repository created again under the name does not inherit the token.
1221 c.seed("myaction", domain.VisibilityPrivate, "action.yml")
1222 _, err = c.forge.Auth.Authenticate(c.ctx, arch)
1223 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
1224 status, _, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+sha, basic(arch))
1225 assert.Equal(t, http.StatusUnauthorized, status)
1226
1227 // The job ending afterwards revokes what is left without an error.
1228 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
1229}
1230
1231// GO-005: ls-remote runs are bounded server-wide and a call has a deadline.
1232func TestResolveActionsBoundsRemoteWork(t *testing.T) {
1233 t.Run("concurrency", func(t *testing.T) {
1234 // The jobs of a run resolve at the same time: five calls, two slots.
1235 var running, peak atomic.Int32
1236 release := make(chan struct{})
1237 remote := newFakeRemote(t)
1238 remote.m.ExpectedCalls = nil
1239 remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1240 func(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
1241 n := running.Add(1)
1242 defer running.Add(-1)
1243 for {
1244 p := peak.Load()
1245 if n <= p || peak.CompareAndSwap(p, n) {
1246 break
1247 }
1248 }
1249 select {
1250 case <-release:
1251 case <-ctx.Done():
1252 return nil, ctx.Err()
1253 }
1254 return []ports.RemoteRef{{Name: "refs/tags/v1", ID: shaMain}}, nil
1255 }).Maybe()
1256 c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.RemoteConcurrency = 2 })
1257 runner := c.registerRunner("alpha", "ubuntu-latest")
1258 job := c.startJob(runner)
1259 c.heartbeat(runner, job)
1260
1261 var wg sync.WaitGroup
1262 results := make([][]*pb.ResolvedAction, 5)
1263 errs := make([]error, 5)
1264 for i, repo := range []string{"one", "two", "three", "four", "five"} {
1265 wg.Add(1)
1266 go func() {
1267 defer wg.Done()
1268 results[i], errs[i] = c.resolveErr(runner, job, "a/"+repo+"@v1")
1269 }()
1270 }
1271 require.Eventually(t, func() bool { return running.Load() == 2 }, 5*time.Second, 10*time.Millisecond)
1272 time.Sleep(150 * time.Millisecond)
1273 assert.EqualValues(t, 2, running.Load(), "the others wait for a slot")
1274 close(release)
1275 wg.Wait()
1276 assert.LessOrEqual(t, peak.Load(), int32(2))
1277 for i := range results {
1278 require.NoError(t, errs[i])
1279 assert.Empty(t, results[i][0].GetError(), results[i][0].GetRef())
1280 }
1281 })
1282
1283 t.Run("deadline", func(t *testing.T) {
1284 remote := newFakeRemote(t)
1285 remote.m.ExpectedCalls = nil
1286 remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1287 func(ctx context.Context, _, _ string) ([]ports.RemoteRef, error) {
1288 <-ctx.Done()
1289 return nil, ctx.Err()
1290 }).Maybe()
1291 c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.ResolveTimeout = 300 * time.Millisecond })
1292 runner := c.registerRunner("alpha", "ubuntu-latest")
1293 job := c.startJob(runner)
1294 c.heartbeat(runner, job)
1295 start := time.Now()
1296 _, err := c.resolveErr(runner, job, "slow/action@v1")
1297 assert.Equal(t, connect.CodeUnavailable, ciErrCode(err), "a deadline of ours is unavailable, not deadline_exceeded")
1298 assert.Less(t, time.Since(start), 5*time.Second)
1299 // The job goes on: the next call is answered.
1300 ok, err := c.resolveErr(runner, job, "a/b@"+shaMain)
1301 require.NoError(t, err)
1302 assert.Empty(t, ok[0].GetError())
1303 })
1304}
1305
1306// mergeWorkflow runs on main only, so that the branch that carries it runs
1307// nothing until it is merged.
1308const mergeWorkflow = `on:
1309 push:
1310 branches: [main]
1311jobs:
1312 j:
1313 runs-on: ubuntu-latest
1314 steps:
1315 - uses: owner/private-action@v1
1316`
1317
1318// GO-R1: a merge runs the workflow with the rights of the principal that
1319// merged, not the owner's: a token limited to a repository that merges a
1320// workflow using a private action of another repository is told "repository not
1321// found", while the owner merging the same workflow gets the action. The actor
1322// shown on the run stays the owner, as GitHub shows it.
1323func TestMergeResolvesActionsWithTheRightsOfTheMerger(t *testing.T) {
1324 merge := func(c *ciHarness, as func(mr int64, head string) error) (*pb.Job, *pb.Run) {
1325 c.t.Helper()
1326 runner := c.registerRunner("alpha", "ubuntu-latest")
1327 c.push("main", map[string]string{"README": "x\n"}, "")
1328 feat := c.push("feat", map[string]string{".github/workflows/m.yml": mergeWorkflow}, "")
1329 mr, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "CI", SourceBranch: "feat"}))
1330 require.NoError(c.t, err)
1331 require.Empty(c.t, c.runs(), "nothing runs before the merge")
1332 require.NoError(c.t, as(mr.Msg.GetMergeRequest().GetNumber(), feat))
1333 job := runner.await()
1334 c.heartbeat(runner, job)
1335 return job, c.runs()[0]
1336 }
1337 setup := func(t *testing.T) *ciHarness {
1338 c := newExecHarness(t, newFakeRemote(t), nil)
1339 c.seed("private-action", domain.VisibilityPrivate, "action.yml")
1340 c.mustGit(c.repoDir("private-action"), "tag", "v1")
1341 return c
1342 }
1343 asToken := func(c *ciHarness, secret string) func(int64, string) error {
1344 return func(n int64, head string) error {
1345 _, err := c.api(secret).mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: head}))
1346 return err
1347 }
1348 }
1349
1350 t.Run("a token limited to the repository", func(t *testing.T) {
1351 c := setup(t)
1352 secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRMerge}, nil, "proj")
1353 job, run := merge(c, asToken(c, secret))
1354 assert.Equal(t, "owner", run.GetActor(), "GitHub shows the owner as the actor of a merge")
1355 got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, "owner/private-action@v1")
1356 assert.Equal(t, "Unable to resolve action `owner/private-action@v1`, repository not found", got[0].GetError())
1357 assert.Empty(t, got[0].GetToken())
1358 })
1359
1360 t.Run("a token that lists both repositories", func(t *testing.T) {
1361 c := setup(t)
1362 secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRMerge}, nil, "proj", "private-action")
1363 job, _ := merge(c, asToken(c, secret))
1364 got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, "owner/private-action@v1")
1365 require.Empty(t, got[0].GetError())
1366 assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource())
1367 })
1368
1369 t.Run("the owner", func(t *testing.T) {
1370 c := setup(t)
1371 job, run := merge(c, func(n int64, head string) error {
1372 id, err := domain.ParseObjectID(head)
1373 require.NoError(t, err)
1374 _, err = c.forge.MergeRequests.Merge(c.ctx, domain.Owner(), c.h("proj"), n, ports.MergeParams{HeadID: id})
1375 return err
1376 })
1377 assert.Equal(t, "owner", run.GetActor())
1378 got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, "owner/private-action@v1")
1379 require.Empty(t, got[0].GetError())
1380 assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource())
1381 assert.True(t, domain.IsRepoToken(got[0].GetToken()))
1382 })
1383
1384 t.Run("the admin token", func(t *testing.T) {
1385 c := setup(t)
1386 job, _ := merge(c, asToken(c, adminToken))
1387 got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, "owner/private-action@v1")
1388 require.Empty(t, got[0].GetError())
1389 })
1390}
1391
1392// GO-R4: a repository of this server that the pusher cannot read never falls
1393// through to github.com under the same name, at the first resolution and when
1394// the answer comes from a stored one; only a repository that does not exist
1395// here is github.com's.
1396func TestAnActionRepositoryOfTheServerNeverFallsThroughToGitHub(t *testing.T) {
1397 const ref = "owner/b@v1"
1398 notFound := "Unable to resolve action `owner/b@v1`, repository not found"
1399 limited := func(c *ciHarness) string {
1400 s, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
1401 return s
1402 }
1403 setup := func(t *testing.T) (*ciHarness, *fakeRemote, *fakeRunner) {
1404 remote := newFakeRemote(t)
1405 remote.set("owner/b", ptrRef("refs/tags/v1", shaOther)) // the repository of the same name on github.com
1406 c := newExecHarness(t, remote, nil)
1407 return c, remote, c.registerRunner("alpha", "ubuntu-latest")
1408 }
1409 // run pushes the workflow (first) or a new commit as the secret and returns the
1410 // job the push made.
1411 run := func(c *ciHarness, runner *fakeRunner, secret string, files map[string]string) *pb.Job {
1412 c.t.Helper()
1413 c.pushAs(secret, "main", files)
1414 job := runner.await()
1415 c.heartbeat(runner, job)
1416 return job
1417 }
1418 resolve := func(c *ciHarness, job *pb.Job) *pb.ResolvedAction {
1419 return c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, ref)[0]
1420 }
1421
1422 t.Run("first resolution", func(t *testing.T) {
1423 c, remote, runner := setup(t)
1424 c.seed("b", domain.VisibilityPrivate, "action.yml")
1425 c.mustGit(c.repoDir("b"), "tag", "v1")
1426 job := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()})
1427 got := resolve(c, job)
1428 assert.Equal(t, notFound, got.GetError(), "the same text as for a repository that is not there")
1429 assert.Empty(t, got.GetResolvedSha())
1430 assert.Empty(t, got.GetTarballUrl())
1431 assert.Empty(t, got.GetToken())
1432 assert.False(t, remote.asked("owner/b"), "github.com is never asked about a name the server holds")
1433 // And not the second time, from the same attempt.
1434 assert.Equal(t, notFound, resolve(c, job).GetError())
1435 assert.False(t, remote.asked("owner/b"))
1436 })
1437
1438 t.Run("a pusher who reads it, then one who does not, on the same ref", func(t *testing.T) {
1439 c, remote, runner := setup(t)
1440 c.seed("b", domain.VisibilityPrivate, "action.yml")
1441 c.mustGit(c.repoDir("b"), "tag", "v1")
1442 bSha := c.bareGit("b", "rev-parse", "HEAD")
1443
1444 first := run(c, runner, adminToken, map[string]string{oneJobWorkflow: oneJobYAML()})
1445 got := resolve(c, first)
1446 require.Empty(t, got.GetError())
1447 assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got.GetSource())
1448 assert.Equal(t, bSha, got.GetResolvedSha())
1449
1450 second := run(c, runner, limited(c), map[string]string{"two.txt": "2\n"})
1451 got = resolve(c, second)
1452 assert.Equal(t, notFound, got.GetError(), "another run, another pusher: its own answer")
1453 assert.Empty(t, got.GetToken())
1454 assert.False(t, remote.asked("owner/b"))
1455 // The first run still resolves.
1456 assert.Equal(t, bSha, resolve(c, first).GetResolvedSha())
1457 })
1458
1459 t.Run("a pusher who does not read it, then one who does, on the same ref", func(t *testing.T) {
1460 c, remote, runner := setup(t)
1461 c.seed("b", domain.VisibilityPrivate, "action.yml")
1462 c.mustGit(c.repoDir("b"), "tag", "v1")
1463 bSha := c.bareGit("b", "rev-parse", "HEAD")
1464
1465 first := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()})
1466 assert.Equal(t, notFound, resolve(c, first).GetError())
1467
1468 second := run(c, runner, adminToken, map[string]string{"two.txt": "2\n"})
1469 got := resolve(c, second)
1470 require.Empty(t, got.GetError(), "the refusal of the first run is not kept for the second")
1471 assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got.GetSource())
1472 assert.Equal(t, bSha, got.GetResolvedSha())
1473 assert.NotEqual(t, strings.ToLower(shaOther), got.GetResolvedSha(), "not github.com's commit")
1474 assert.False(t, remote.asked("owner/b"))
1475 assert.Equal(t, notFound, resolve(c, first).GetError(), "and the first run is still refused")
1476 })
1477
1478 t.Run("a stored resolution from github.com, then the repository appears here", func(t *testing.T) {
1479 c, remote, runner := setup(t)
1480 job := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()})
1481 before := resolve(c, job)
1482 require.Empty(t, before.GetError(), "no such repository here: github.com answers")
1483 assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, before.GetSource())
1484 assert.Equal(t, strings.ToLower(shaOther), before.GetResolvedSha())
1485 require.Equal(t, 1, remote.count())
1486
1487 // The name is taken on the server, privately.
1488 c.seed("b", domain.VisibilityPrivate, "action.yml")
1489 after := resolve(c, job)
1490 assert.Equal(t, notFound, after.GetError(), "the stored github.com answer is not served for a repository the pusher may not read")
1491 assert.Empty(t, after.GetResolvedSha())
1492 assert.Empty(t, after.GetTarballUrl())
1493 assert.Equal(t, 1, remote.count(), "and github.com was not asked again")
1494 })
1495
1496 t.Run("a stored resolution from github.com, a pusher who reads the new repository", func(t *testing.T) {
1497 c, _, runner := setup(t)
1498 job := run(c, runner, adminToken, map[string]string{oneJobWorkflow: oneJobYAML()})
1499 before := resolve(c, job)
1500 require.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, before.GetSource())
1501 c.seed("b", domain.VisibilityPrivate, "action.yml")
1502 after := resolve(c, job)
1503 require.Empty(t, after.GetError())
1504 assert.Equal(t, before.GetResolvedSha(), after.GetResolvedSha(), "stable for the attempt")
1505 assert.Empty(t, after.GetToken(), "no archive token for a commit of github.com")
1506 })
1507
1508 t.Run("a stored resolution from the server, then the repository goes", func(t *testing.T) {
1509 c, remote, runner := setup(t)
1510 c.seed("b", domain.VisibilityPrivate, "action.yml")
1511 c.mustGit(c.repoDir("b"), "tag", "v1")
1512 job := run(c, runner, adminToken, map[string]string{oneJobWorkflow: oneJobYAML()})
1513 require.Empty(t, resolve(c, job).GetError())
1514 require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("b")))
1515 got := resolve(c, job)
1516 assert.Equal(t, notFound, got.GetError(), "there is nothing left to serve the stored commit from")
1517 assert.Empty(t, got.GetToken())
1518 assert.False(t, remote.asked("owner/b"))
1519 })
1520
1521 t.Run("a repository that does not exist here is github.com's", func(t *testing.T) {
1522 c, remote, runner := setup(t)
1523 job := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()})
1524 got := resolve(c, job)
1525 require.Empty(t, got.GetError())
1526 assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, got.GetSource())
1527 assert.True(t, remote.asked("owner/b"))
1528 })
1529
1530 t.Run("the owner's case does not open a way around", func(t *testing.T) {
1531 c, remote, runner := setup(t)
1532 c.seed("b", domain.VisibilityPrivate, "action.yml")
1533 c.mustGit(c.repoDir("b"), "tag", "v1")
1534 job := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()})
1535 for _, u := range []string{"OWNER/b@v1", "Owner/B@v1", "owner/B@v1"} {
1536 got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, u)[0]
1537 assert.Equal(t, "Unable to resolve action `"+strings.TrimSuffix(u, "@v1")+"@v1`, repository not found", got.GetError(), u)
1538 }
1539 assert.Zero(t, remote.count())
1540 })
1541}