v0.19.0 55a688e

ci_execution_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 44

↑↓ moveenter openesc close