v0.25.0 4492065

api_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bytes"
7	"context"
8	"crypto/rand"
9	"fmt"
10	"io"
11	"net"
12	"net/http"
13	"net/http/httptest"
14	"path/filepath"
15	"strings"
16	"testing"
17	"time"
18
19	"connectrpc.com/connect"
20	"github.com/stretchr/testify/assert"
21	"github.com/stretchr/testify/require"
22	"google.golang.org/protobuf/types/known/fieldmaskpb"
23	"google.golang.org/protobuf/types/known/timestamppb"
24
25	pb "bnema.dev/ermite-api/gen/ermite"
26	"bnema.dev/ermite/internal/adapters/rpc"
27	"bnema.dev/ermite/internal/clientlimit"
28	"bnema.dev/ermite/internal/domain"
29	"bnema.dev/ermite/internal/ports"
30)
31
32// principals of the access tables.
33const (
34	pAnon    = "anonymous"
35	pRoot    = "root"
36	pReader  = "reader"  // repo:read on every repository
37	pLimited = "limited" // read, write, admin and token:admin on a list of repositories
38	pNoScope = "noscope" // a token with no repository scope
39	pBad     = "badtoken"
40)
41
42// accessRig is a harness with one caller per principal.
43type accessRig struct {
44	*harness
45	who map[string]*clients
46}
47
48func newAccessRig(t *testing.T) *accessRig {
49	t.Helper()
50	h := newHarness(t, harnessOpts{trustedProxies: "127.0.0.0/8"})
51	// The limited token may touch "mine" and the fresh repositories m1…m99.
52	listed := []string{"mine"}
53	for i := 1; i < 100; i++ {
54		listed = append(listed, fmt.Sprintf("m%d", i))
55	}
56	r := &accessRig{harness: h, who: map[string]*clients{
57		pAnon:   h.api(""),
58		pRoot:   h.api(adminToken),
59		pReader: h.api(h.token(scopes(domain.ScopeRepoRead))),
60		pLimited: h.api(h.token(scopes(
61			domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin, domain.ScopeTokenAdmin), listed...)),
62		pNoScope: h.api(h.token(scopes(domain.ScopeMRWrite))),
63		pBad:     newDisposableClients(h.url("/-/api"), "ermite_"+strings.Repeat("x", 43)),
64	}}
65	return r
66}
67
68// every principal in a stable order.
69var allPrincipals = []string{pAnon, pRoot, pReader, pLimited, pNoScope, pBad}
70
71// TestListPathsRPC: the owner lists public and private repositories, an
72// anonymous caller sees the private one as missing, and so is a bad revision.
73func TestListPathsRPC(t *testing.T) {
74	r := newAccessRig(t)
75	r.seed("pub", domain.VisibilityPublic, "README.md", "docs/a.txt")
76	r.seed("priv", domain.VisibilityPrivate, "README.md")
77	ctx := r.ctx
78	root, anon := r.who[pRoot], r.who[pAnon]
79
80	list := func(c *clients, repo, rev string) (*pb.ListPathsResponse, error) {
81		resp, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo, Revision: rev}))
82		if err != nil {
83			return nil, err
84		}
85		return resp.Msg, nil
86	}
87
88	pub, err := list(root, "pub", "")
89	require.NoError(t, err)
90	assert.Equal(t, []string{"README.md", "docs/a.txt"}, pub.GetPaths())
91	assert.NotEmpty(t, pub.GetCommitId())
92	assert.NotEmpty(t, pub.GetTreeId())
93	assert.False(t, pub.GetTruncated())
94
95	priv, err := list(root, "priv", "")
96	require.NoError(t, err)
97	assert.Equal(t, []string{"README.md"}, priv.GetPaths())
98
99	_, err = list(anon, "priv", "")
100	assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err))
101	_, err = list(root, "pub", "nope")
102	assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err))
103}
104
105// TestAC2ReadAccess: every read RPC on a public repository, a private one and
106// one inside the limited token's list.
107func TestAC2ReadAccess(t *testing.T) {
108	r := newAccessRig(t)
109	r.seed("pub", domain.VisibilityPublic, "README.md", "docs/a.txt")
110	r.seed("priv", domain.VisibilityPrivate, "README.md")
111	r.seed("mine", domain.VisibilityPrivate, "README.md")
112	ctx := r.ctx
113
114	rpcs := map[string]func(c *clients, repo string) error{
115		"GetRepo": func(c *clients, repo string) error {
116			_, err := c.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: repo}))
117			return err
118		},
119		"ListRefs": func(c *clients, repo string) error {
120			_, err := c.git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{Repo: repo}))
121			return err
122		},
123		"GetTree": func(c *clients, repo string) error {
124			_, err := c.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: repo}))
125			return err
126		},
127		"ListPaths": func(c *clients, repo string) error {
128			_, err := c.git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo}))
129			return err
130		},
131		"GetBlob": func(c *clients, repo string) error {
132			_, err := readBlob(ctx, c, &pb.GetBlobRequest{Repo: repo, Path: "README.md"})
133			return err
134		},
135		"ListCommits": func(c *clients, repo string) error {
136			_, err := c.git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo}))
137			return err
138		},
139		"GetCommit": func(c *clients, repo string) error {
140			_, err := c.git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: repo}))
141			return err
142		},
143		"ResolveRevision": func(c *clients, repo string) error {
144			_, err := c.git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: "main"}))
145			return err
146		},
147	}
148	// want[principal] is the outcome for pub, priv, mine.
149	want := map[string][3]string{
150		pAnon:    {"ok", "not_found", "not_found"},
151		pRoot:    {"ok", "ok", "ok"},
152		pReader:  {"ok", "ok", "ok"},
153		pLimited: {"ok", "not_found", "ok"},
154		pNoScope: {"ok", "not_found", "not_found"},
155		pBad:     {"unauthenticated", "unauthenticated", "unauthenticated"},
156	}
157	for rpcName, call := range rpcs {
158		for _, who := range allPrincipals {
159			for i, repo := range []string{"pub", "priv", "mine"} {
160				err := call(r.who[who], repo)
161				assert.Equal(t, want[who][i], codeName(err), "%s as %s on %s", rpcName, who, repo)
162			}
163		}
164	}
165
166	t.Run("a hidden repository looks like a missing one", func(t *testing.T) {
167		for rpcName, call := range rpcs {
168			hidden := call(r.who[pAnon], "priv")
169			missing := call(r.who[pAnon], "nothing-here")
170			require.Error(t, hidden, rpcName)
171			assert.Equal(t, missing.Error(), hidden.Error(), rpcName)
172		}
173	})
174
175	t.Run("ListRepos shows what the caller may read", func(t *testing.T) {
176		names := func(who string) []string {
177			resp, err := r.who[who].repo.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{}))
178			if err != nil {
179				return []string{codeName(err)}
180			}
181			var out []string
182			for _, repo := range resp.Msg.GetRepos() {
183				out = append(out, repo.GetName())
184			}
185			return out
186		}
187		assert.Equal(t, []string{"pub"}, names(pAnon))
188		assert.Equal(t, []string{"mine", "priv", "pub"}, names(pRoot))
189		assert.Equal(t, []string{"mine", "priv", "pub"}, names(pReader))
190		assert.Equal(t, []string{"mine", "pub"}, names(pLimited))
191		assert.Equal(t, []string{"pub"}, names(pNoScope))
192		assert.Equal(t, []string{"unauthenticated"}, names(pBad))
193	})
194
195	t.Run("GetVersion needs no authentication", func(t *testing.T) {
196		for _, who := range allPrincipals {
197			resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198			require.NoError(t, err, who)
199			assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts"}, resp.Msg.GetFeatures(), who)
200			assert.NotEmpty(t, resp.Msg.GetVersion())
201		}
202	})
203}
204
205// writeCell is one cell of the write table.
206type writeCell struct {
207	name string
208	// vis is the visibility of the repository made for the cell; "" makes none.
209	vis domain.Visibility
210	// prefix is "m" for a repository inside the limited token's list, "x" outside.
211	prefix string
212	call   func(c *clients, repo string) error
213	want   map[string]string
214}
215
216// TestAC2WriteAccess: the mutating RPCs for every principal.
217func TestAC2WriteAccess(t *testing.T) {
218	r := newAccessRig(t)
219	ctx := r.ctx
220
221	desc := func(c *clients, repo string) error {
222		_, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
223			Repo: repo, Description: "hello", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}},
224		}))
225		return err
226	}
227	del := func(c *clients, repo string) error {
228		_, err := c.repo.DeleteRepo(ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: repo}))
229		return err
230	}
231	create := func(c *clients, repo string) error {
232		_, err := c.repo.CreateRepo(ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: repo}))
233		return err
234	}
235	createToken := func(c *clients, _ string) error {
236		_, err := c.token.CreateToken(ctx, connect.NewRequest(&pb.CreateTokenRequest{
237			Label: "t", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, Repos: []string{"m1"},
238		}))
239		return err
240	}
241	listTokens := func(c *clients, _ string) error {
242		_, err := c.token.ListTokens(ctx, connect.NewRequest(&pb.ListTokensRequest{}))
243		return err
244	}
245	revoke := func(c *clients, _ string) error {
246		_, err := c.token.RevokeToken(ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: "0000000000000000"}))
247		return err
248	}
249
250	ok, nf, denied, unauth := "ok", "not_found", "permission_denied", "unauthenticated"
251	cells := []writeCell{
252		{"CreateRepo", "", "m", create, map[string]string{pAnon: unauth, pRoot: ok, pReader: denied, pLimited: ok, pNoScope: denied, pBad: unauth}},
253		{"CreateRepo outside the list", "", "x", create, map[string]string{pAnon: unauth, pRoot: ok, pReader: denied, pLimited: denied, pNoScope: denied, pBad: unauth}},
254		{"UpdateRepo private in list", domain.VisibilityPrivate, "m", desc, map[string]string{pAnon: nf, pRoot: ok, pReader: denied, pLimited: ok, pNoScope: nf, pBad: unauth}},
255		{"UpdateRepo private outside the list", domain.VisibilityPrivate, "x", desc, map[string]string{pAnon: nf, pRoot: ok, pReader: denied, pLimited: nf, pNoScope: nf, pBad: unauth}},
256		{"UpdateRepo public outside the list", domain.VisibilityPublic, "x", desc, map[string]string{pAnon: unauth, pRoot: ok, pReader: denied, pLimited: denied, pNoScope: denied, pBad: unauth}},
257		{"DeleteRepo private in list", domain.VisibilityPrivate, "m", del, map[string]string{pAnon: nf, pRoot: ok, pReader: denied, pLimited: ok, pNoScope: nf, pBad: unauth}},
258		{"DeleteRepo private outside the list", domain.VisibilityPrivate, "x", del, map[string]string{pAnon: nf, pRoot: ok, pReader: denied, pLimited: nf, pNoScope: nf, pBad: unauth}},
259		{"DeleteRepo public outside the list", domain.VisibilityPublic, "x", del, map[string]string{pAnon: unauth, pRoot: ok, pReader: denied, pLimited: denied, pNoScope: denied, pBad: unauth}},
260		{"CreateToken", "", "", createToken, map[string]string{pAnon: unauth, pRoot: ok, pReader: denied, pLimited: ok, pNoScope: denied, pBad: unauth}},
261		{"ListTokens", "", "", listTokens, map[string]string{pAnon: unauth, pRoot: ok, pReader: denied, pLimited: ok, pNoScope: denied, pBad: unauth}},
262		{"RevokeToken", "", "", revoke, map[string]string{pAnon: unauth, pRoot: nf, pReader: denied, pLimited: nf, pNoScope: denied, pBad: unauth}},
263	}
264	counter := 0
265	for _, cell := range cells {
266		for _, who := range allPrincipals {
267			counter++
268			repo := fmt.Sprintf("%s%d", cell.prefix, counter%99+1)
269			if cell.prefix == "x" {
270				repo = fmt.Sprintf("x%d", counter)
271			}
272			if cell.vis != "" {
273				_, err := r.forge.Repos.Create(ctx, domain.Root(), createParams(r.harness, repo, cell.vis))
274				require.NoError(t, err)
275			}
276			err := cell.call(r.who[who], repo)
277			assert.Equal(t, cell.want[who], codeName(err), "%s as %s on %s: %v", cell.name, who, repo, err)
278		}
279	}
280}
281
282// TestTokenRPCs: the secret is returned once, listed tokens never carry it,
283// and a limited token cannot create a stronger one.
284func TestTokenRPCs(t *testing.T) {
285	r := newAccessRig(t)
286	ctx := r.ctx
287	root := r.who[pRoot]
288
289	exp := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
290	created, err := root.token.CreateToken(ctx, connect.NewRequest(&pb.CreateTokenRequest{
291		Label: "laptop", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ, pb.Scope_SCOPE_REPO_WRITE},
292		Repos: []string{"a", "b"}, ExpiresAt: timestamppb.New(exp),
293	}))
294	require.NoError(t, err)
295	tok := created.Msg.GetToken()
296	assert.True(t, strings.HasPrefix(created.Msg.GetSecret(), "ermite_"))
297	assert.Equal(t, "laptop", tok.GetLabel())
298	assert.Equal(t, []pb.Scope{pb.Scope_SCOPE_REPO_READ, pb.Scope_SCOPE_REPO_WRITE}, tok.GetScopes())
299	assert.Equal(t, []string{"a", "b"}, tok.GetRepos())
300	assert.Equal(t, exp, tok.GetExpiresAt().AsTime())
301	assert.NotNil(t, tok.GetCreatedAt())
302	assert.Nil(t, tok.GetLastUsedAt())
303
304	// The secret works.
305	_, err = r.api(created.Msg.GetSecret()).repo.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{}))
306	require.NoError(t, err)
307
308	list, err := root.token.ListTokens(ctx, connect.NewRequest(&pb.ListTokensRequest{}))
309	require.NoError(t, err)
310	var found bool
311	for _, tk := range list.Msg.GetTokens() {
312		found = found || tk.GetId() == tok.GetId()
313	}
314	assert.True(t, found)
315	assert.NotContains(t, list.Msg.String(), created.Msg.GetSecret())
316
317	// Paging: one at a time, newest first, no repeats.
318	seen := map[string]bool{}
319	var pageToken string
320	for range 20 {
321		page, err := root.token.ListTokens(ctx, connect.NewRequest(&pb.ListTokensRequest{PageSize: 1, PageToken: pageToken}))
322		require.NoError(t, err)
323		for _, tk := range page.Msg.GetTokens() {
324			assert.False(t, seen[tk.GetId()], "token listed twice")
325			seen[tk.GetId()] = true
326		}
327		if pageToken = page.Msg.GetNextPageToken(); pageToken == "" {
328			break
329		}
330	}
331	assert.Len(t, seen, len(list.Msg.GetTokens()))
332	_, err = root.token.ListTokens(ctx, connect.NewRequest(&pb.ListTokensRequest{PageToken: "garbage"}))
333	assert.Equal(t, "invalid_argument", codeName(err))
334
335	// No escalation: the limited token cannot grant more than it has.
336	limited := r.who[pLimited]
337	for name, req := range map[string]*pb.CreateTokenRequest{
338		"a scope it lacks":     {Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_CI_WRITE}, Repos: []string{"m1"}},
339		"no repository list":   {Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}},
340		"a repository outside": {Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, Repos: []string{"other"}},
341	} {
342		_, err := limited.token.CreateToken(ctx, connect.NewRequest(req))
343		assert.Equal(t, "permission_denied", codeName(err), name)
344	}
345	// Invalid requests.
346	for name, req := range map[string]*pb.CreateTokenRequest{
347		"no scope":          {Label: "x"},
348		"unspecified scope": {Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_UNSPECIFIED}},
349		"empty label":       {Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}},
350		"reserved owner":    {Label: "owner", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}},
351		"reserved root":     {Label: " Root ", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}},
352		"expired":           {Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, ExpiresAt: timestamppb.New(time.Now().Add(-time.Hour))},
353		"bad repository":    {Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, Repos: []string{"Bad Name"}},
354	} {
355		_, err := root.token.CreateToken(ctx, connect.NewRequest(req))
356		assert.Equal(t, "invalid_argument", codeName(err), name)
357	}
358
359	// Revoke: the token stops working at once.
360	_, err = root.token.RevokeToken(ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: tok.GetId()}))
361	require.NoError(t, err)
362	_, err = r.api(created.Msg.GetSecret()).repo.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{}))
363	assert.Equal(t, "unauthenticated", codeName(err))
364	_, err = root.token.RevokeToken(ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: tok.GetId()}))
365	assert.Equal(t, "not_found", codeName(err))
366}
367
368// TestAuthFailuresAreCountedAndBlock: bad tokens on the API count toward the
369// same budget as on the Git routes, and a blocked client gets
370// resource_exhausted (HTTP 429) whatever it presents.
371func TestAuthFailuresAreCountedAndBlock(t *testing.T) {
372	h := newHarness(t, harnessOpts{})
373	h.seed("pub", domain.VisibilityPublic, "README.md")
374	bad := h.api("ermite_" + strings.Repeat("y", 43))
375	req := func() *connect.Request[pb.GetRepoRequest] { return connect.NewRequest(&pb.GetRepoRequest{Repo: "pub"}) }
376
377	for range clientlimit.MaxFailures {
378		_, err := bad.repo.GetRepo(h.ctx, req())
379		require.Equal(t, "unauthenticated", codeName(err))
380		var ce *connect.Error
381		require.ErrorAs(t, err, &ce)
382	}
383	_, err := bad.repo.GetRepo(h.ctx, req())
384	assert.Equal(t, "resource_exhausted", codeName(err))
385	var ce *connect.Error
386	require.ErrorAs(t, err, &ce)
387	assert.NotEmpty(t, ce.Meta().Get("Retry-After"))
388
389	// Even a valid token and anonymous calls are refused while blocked ...
390	_, err = h.api(adminToken).repo.GetRepo(h.ctx, req())
391	assert.Equal(t, "resource_exhausted", codeName(err))
392	_, err = h.api("").repo.GetRepo(h.ctx, req())
393	assert.Equal(t, "resource_exhausted", codeName(err))
394	// ... and so is the Git route: one budget per client.
395	assert.Equal(t, http.StatusTooManyRequests, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil))
396
397	// The rejection is on the Connect protocol, with HTTP 429.
398	httpReq, _ := http.NewRequest(http.MethodPost, h.url("/-/api/ermite.RepoService/GetRepo"), strings.NewReader(`{"repo":"pub"}`))
399	httpReq.Header.Set("Content-Type", "application/json")
400	resp, err := http.DefaultClient.Do(httpReq)
401	require.NoError(t, err)
402	body, _ := io.ReadAll(resp.Body)
403	_ = resp.Body.Close()
404	assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode)
405	assert.Contains(t, string(body), `"resource_exhausted"`)
406}
407
408// TestUnauthenticatedIsAConnectError: a bad token is a Connect error with a
409// JSON body, not a bare HTTP 401 page; the secret never appears in the logs.
410func TestUnauthenticatedIsAConnectError(t *testing.T) {
411	h := newHarness(t, harnessOpts{})
412	secret := "ermite_" + strings.Repeat("z", 43)
413	for _, hdr := range []string{"Bearer " + secret, "Basic dXNlcjpwYXNz", "Bearer", "token " + secret} {
414		req, _ := http.NewRequest(http.MethodPost, h.url("/-/api/ermite.RepoService/ListRepos"), strings.NewReader(`{}`))
415		req.Header.Set("Content-Type", "application/json")
416		req.Header.Set("Authorization", hdr)
417		resp, err := http.DefaultClient.Do(req)
418		require.NoError(t, err)
419		body, _ := io.ReadAll(resp.Body)
420		_ = resp.Body.Close()
421		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, hdr)
422		assert.Equal(t, "application/json", resp.Header.Get("Content-Type"), hdr)
423		assert.Contains(t, string(body), `"code":"unauthenticated"`, hdr)
424	}
425	assert.NotContains(t, h.logs.String(), secret)
426}
427
428// TestCodecsAndPaths: binary and JSON codecs, the mount point, and the
429// request log with procedure and code.
430func TestCodecsAndPaths(t *testing.T) {
431	h := newHarness(t, harnessOpts{})
432	h.seed("pub", domain.VisibilityPublic, "README.md")
433
434	for name, opts := range map[string][]connect.ClientOption{
435		"binary":      nil,
436		"json":        {connect.WithProtoJSON()},
437		"grpc-binary": {connect.WithGRPC()},
438	} {
439		c := h.api("", opts...)
440		resp, err := c.repo.GetRepo(h.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "pub"}))
441		require.NoError(t, err, name)
442		assert.Equal(t, "pub", resp.Msg.GetRepo().GetName(), name)
443		assert.Equal(t, "main", resp.Msg.GetRepo().GetDefaultBranch(), name)
444		assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, resp.Msg.GetRepo().GetVisibility(), name)
445		_, err = c.repo.GetRepo(h.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "nothing"}))
446		assert.Equal(t, "not_found", codeName(err), name)
447	}
448
449	// curl-style JSON, at the documented path.
450	resp, err := http.Post(h.url("/-/api/ermite.RepoService/GetRepo"), "application/json", strings.NewReader(`{"repo":"pub"}`))
451	require.NoError(t, err)
452	body, _ := io.ReadAll(resp.Body)
453	_ = resp.Body.Close()
454	assert.Equal(t, 200, resp.StatusCode)
455	assert.Contains(t, string(body), `"name":"pub"`)
456	assert.Equal(t, "DENY", resp.Header.Get("X-Frame-Options"), "security headers apply to the API too")
457
458	// Nothing outside the services.
459	assert.Equal(t, 404, h.status("POST", "/-/api/ermite.RepoService/Nope", nil))
460	assert.Equal(t, 404, h.status("POST", "/-/api/other.Service/Method", nil))
461
462	logs := h.logs.String()
463	assert.Contains(t, logs, `"procedure":"/ermite.RepoService/GetRepo"`)
464	assert.Contains(t, logs, `"code":"not_found"`)
465	assert.Contains(t, logs, `"code":"ok"`)
466}
467
468// TestRPCErrorCodes: validation errors are invalid_argument, not internal.
469func TestRPCErrorCodes(t *testing.T) {
470	h := newHarness(t, harnessOpts{})
471	h.seed("pub", domain.VisibilityPublic, "README.md", "docs/a.txt")
472	root := h.api(adminToken)
473	ctx := h.ctx
474
475	repoCases := map[string]func() error{
476		"get uppercase name": func() error {
477			_, err := root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "Pub"}))
478			return err
479		},
480		"get empty name": func() error {
481			_, err := root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{}))
482			return err
483		},
484		"create invalid name": func() error {
485			_, err := root.repo.CreateRepo(ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "No Good"}))
486			return err
487		},
488		"create reserved name": func() error {
489			_, err := root.repo.CreateRepo(ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "-"}))
490			return err
491		},
492		"create with a .git suffix": func() error {
493			_, err := root.repo.CreateRepo(ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "x.git"}))
494			return err
495		},
496		"create multi-line description": func() error {
497			_, err := root.repo.CreateRepo(ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "ok1", Description: "a\nb"}))
498			return err
499		},
500		"list negative page size": func() error {
501			_, err := root.repo.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{PageSize: -1}))
502			return err
503		},
504		"list bad page token": func() error {
505			_, err := root.repo.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{PageToken: "!!"}))
506			return err
507		},
508		"refs unspecified kind": func() error {
509			_, err := root.git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{Repo: "pub", Kinds: []pb.RefKind{pb.RefKind_REF_KIND_UNSPECIFIED}}))
510			return err
511		},
512		"tree bad path": func() error {
513			_, err := root.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: "pub", Path: "a/../b"}))
514			return err
515		},
516		"tree on a file": func() error {
517			_, err := root.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: "pub", Path: "README.md"}))
518			return err
519		},
520		"blob on a directory": func() error {
521			_, err := readBlob(ctx, root, &pb.GetBlobRequest{Repo: "pub", Path: "docs"})
522			return err
523		},
524		"blob without a path": func() error {
525			_, err := readBlob(ctx, root, &pb.GetBlobRequest{Repo: "pub"})
526			return err
527		},
528		"commits bad revision": func() error {
529			_, err := root.git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: "pub", Revision: "a..b"}))
530			return err
531		},
532		"commits bad page token": func() error {
533			_, err := root.git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: "pub", PageToken: "zz"}))
534			return err
535		},
536		"update visibility unspecified": func() error {
537			_, err := root.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{Repo: "pub", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}}))
538			return err
539		},
540		"update without mask": func() error {
541			_, err := root.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{Repo: "pub", Description: "x"}))
542			return err
543		},
544		"update unknown mask path": func() error {
545			_, err := root.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{Repo: "pub", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"name"}}}))
546			return err
547		},
548		"watch unknown topic": func() error { return watchErr(ctx, root, "everything") },
549		"watch bad repo name": func() error { return watchErr(ctx, root, "repo:Bad Name") },
550	}
551	for name, call := range repoCases {
552		assert.Equal(t, "invalid_argument", codeName(call()), name)
553	}
554
555	notFound := map[string]func() error{
556		"commits unknown revision": func() error {
557			_, err := root.git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: "pub", Revision: "nope"}))
558			return err
559		},
560		"resolve unknown revision": func() error {
561			_, err := root.git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: "pub", Revision: "nope"}))
562			return err
563		},
564		"tree unknown path": func() error {
565			_, err := root.git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: "pub", Path: "nowhere"}))
566			return err
567		},
568		"blob unknown path": func() error {
569			_, err := readBlob(ctx, root, &pb.GetBlobRequest{Repo: "pub", Path: "nowhere.txt"})
570			return err
571		},
572		"update unknown repository": func() error {
573			_, err := root.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{Repo: "ghost", Description: "x", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}}))
574			return err
575		},
576		"delete unknown repository": func() error {
577			_, err := root.repo.DeleteRepo(ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "ghost"}))
578			return err
579		},
580		"watch unknown repository": func() error { return watchErr(ctx, root, "repo:ghost") },
581	}
582	for name, call := range notFound {
583		assert.Equal(t, "not_found", codeName(call()), name)
584	}
585
586	// An empty repository has no commit to resolve.
587	h.seed("empty", domain.VisibilityPublic)
588	_, err := root.git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: "empty"}))
589	assert.Equal(t, "not_found", codeName(err))
590	refs, err := root.git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{Repo: "empty"}))
591	require.NoError(t, err)
592	require.Len(t, refs.Msg.GetRefs(), 1, "HEAD of an unborn branch is listed")
593
594	_, err = root.repo.CreateRepo(ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "pub"}))
595	assert.Equal(t, "already_exists", codeName(err))
596
597	_, err = root.repo.CreateRepo(ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "big", Description: strings.Repeat("x", 2<<20)}))
598	assert.Equal(t, "resource_exhausted", codeName(err), "a request over the size limit")
599
600	// A page past the end is empty, not an error.
601	page, err := root.git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: "pub", PageSize: 1}))
602	require.NoError(t, err)
603	require.Len(t, page.Msg.GetCommits(), 1)
604	require.NotEmpty(t, page.Msg.GetNextPageToken())
605	page2, err := root.git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: "pub", PageSize: 1, PageToken: page.Msg.GetNextPageToken()}))
606	require.NoError(t, err)
607	require.Len(t, page2.Msg.GetCommits(), 1)
608	assert.NotEqual(t, page.Msg.GetCommits()[0].GetId(), page2.Msg.GetCommits()[0].GetId())
609	assert.Empty(t, page2.Msg.GetNextPageToken())
610}
611
612// watchErr opens a Watch and returns the error it ends with.
613func watchErr(ctx context.Context, c *clients, topics ...string) error {
614	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
615	defer cancel()
616	st, err := c.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{Topics: topics}))
617	if err != nil {
618		return err
619	}
620	defer func() { _ = st.Close() }()
621	for st.Receive() {
622	}
623	return st.Err()
624}
625
626// TestGetBlobStreamsInChunks: chunks of at most 64 KiB, metadata in the first
627// message only, content intact, binary detected.
628func TestGetBlobStreamsInChunks(t *testing.T) {
629	h := newHarness(t, harnessOpts{})
630	_, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "blobs", domain.VisibilityPublic))
631	require.NoError(t, err)
632
633	big := make([]byte, 3*rpc.BlobChunkSize+1234)
634	_, err = rand.Read(big)
635	require.NoError(t, err)
636	big[10] = 0                                                  // a NUL in the first bytes: binary
637	huge := bytes.Repeat([]byte("line of text\n"), (1<<20)/13+1) // 1 MiB+: past the in-memory limit of gitcli
638	exact := bytes.Repeat([]byte("e"), rpc.BlobChunkSize)
639
640	wt := t.TempDir()
641	h.mustGit(wt, "init", "-q", "--initial-branch=main")
642	h.writeFile(wt, "big.bin", big)
643	h.writeFile(wt, "huge.txt", huge)
644	h.writeFile(wt, "small.txt", []byte("hello\n"))
645	h.writeFile(wt, "empty.txt", nil)
646	h.writeFile(wt, "exact.txt", exact)
647	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/blobs.git"), "main")
648
649	c := h.api("")
650	for _, tc := range []struct {
651		path   string
652		want   []byte
653		binary bool
654		media  string
655	}{
656		{"big.bin", big, true, "application/octet-stream"},
657		{"huge.txt", huge, false, "text/plain"},
658		{"small.txt", []byte("hello\n"), false, "text/plain"},
659		{"empty.txt", nil, false, ""},
660		{"exact.txt", exact, false, "text/plain"},
661	} {
662		t.Run(tc.path, func(t *testing.T) {
663			msgs, err := readBlob(h.ctx, c, &pb.GetBlobRequest{Repo: "blobs", Path: tc.path})
664			require.NoError(t, err)
665			require.NotEmpty(t, msgs)
666			assert.NotNil(t, msgs[0].GetInfo(), "metadata in the first message")
667			var got []byte
668			for i, m := range msgs {
669				if i > 0 {
670					assert.Nil(t, m.GetInfo(), "metadata only in the first message")
671					assert.NotEmpty(t, m.GetChunk(), "no empty chunk after the first message")
672				}
673				assert.LessOrEqual(t, len(m.GetChunk()), rpc.BlobChunkSize)
674				got = append(got, m.GetChunk()...)
675			}
676			assert.True(t, bytes.Equal(tc.want, got), "content differs (%d bytes, want %d)", len(got), len(tc.want))
677			info := msgs[0].GetInfo()
678			assert.Equal(t, int64(len(tc.want)), info.GetSize())
679			assert.Equal(t, tc.binary, info.GetBinary())
680			assert.Equal(t, tc.path, info.GetPath())
681			assert.EqualValues(t, 0o100644, info.GetMode())
682			assert.Len(t, info.GetId(), 40)
683			if tc.media != "" {
684				assert.Contains(t, info.GetMediaType(), tc.media)
685			}
686			if len(tc.want) > rpc.BlobChunkSize {
687				assert.GreaterOrEqual(t, len(msgs), len(tc.want)/rpc.BlobChunkSize)
688			}
689		})
690	}
691}
692
693func createParams(h *harness, name string, vis domain.Visibility) ports.CreateRepoParams {
694	return ports.CreateRepoParams{Name: h.name(name), Visibility: vis}
695}
696
697// TestUpdateRepoDefaultBranch: HEAD moves with git symbolic-ref once the
698// branch is known to exist.
699func TestUpdateRepoDefaultBranch(t *testing.T) {
700	h := newHarness(t, harnessOpts{})
701	h.seed("proj", domain.VisibilityPublic, "README.md")
702	wt := h.workTree("dev", "dev.txt")
703	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "dev")
704	root := h.api(adminToken)
705	ctx := h.ctx
706	mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }
707
708	got, err := root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
709	require.NoError(t, err)
710	assert.Equal(t, "main", got.Msg.GetRepo().GetDefaultBranch())
711
712	// A missing branch changes nothing, even next to a valid field.
713	_, err = root.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
714		Repo: "proj", DefaultBranch: "nope", Description: "never stored", UpdateMask: mask("default_branch", "description"),
715	}))
716	assert.Equal(t, "failed_precondition", codeName(err))
717	assert.Equal(t, "refs/heads/main", h.bareGit("proj", "symbolic-ref", "HEAD"))
718	got, err = root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
719	require.NoError(t, err)
720	assert.Empty(t, got.Msg.GetRepo().GetDescription())
721
722	// A tag is not a branch.
723	h.bareGit("proj", "tag", "v1", "main")
724	_, err = root.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{Repo: "proj", DefaultBranch: "v1", UpdateMask: mask("default_branch")}))
725	assert.Equal(t, "failed_precondition", codeName(err))
726
727	resp, err := root.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
728		Repo: "proj", DefaultBranch: "dev", Description: "the project", Visibility: pb.Visibility_VISIBILITY_PRIVATE,
729		UpdateMask: mask("default_branch", "description", "visibility"),
730	}))
731	require.NoError(t, err)
732	assert.Equal(t, "dev", resp.Msg.GetRepo().GetDefaultBranch())
733	assert.Equal(t, "the project", resp.Msg.GetRepo().GetDescription())
734	assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, resp.Msg.GetRepo().GetVisibility())
735	assert.Equal(t, "refs/heads/dev", h.bareGit("proj", "symbolic-ref", "HEAD"))
736
737	// Reads follow HEAD, and the repository is now hidden from anonymous.
738	refs, err := root.git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{Repo: "proj", Kinds: []pb.RefKind{pb.RefKind_REF_KIND_HEAD}}))
739	require.NoError(t, err)
740	require.Len(t, refs.Msg.GetRefs(), 1)
741	assert.Equal(t, "dev", refs.Msg.GetRefs()[0].GetShortName())
742	_, err = h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
743	assert.Equal(t, "not_found", codeName(err))
744
745	// A clone gets the new default branch.
746	dir := t.TempDir()
747	h.mustGit(dir, "clone", "-q", h.urlAuth(adminToken, "/proj.git"), "c")
748	assert.Equal(t, "dev", strings.TrimSpace(h.mustGit(filepath.Join(dir, "c"), "rev-parse", "--abbrev-ref", "HEAD")))
749}
750
751// TestDeleteRepoMovesToTrash: the directory goes to trash/<name>-<unix>.git,
752// the metadata goes, the name can be reused, and nothing of the old
753// repository leaks into the new one.
754func TestDeleteRepoMovesToTrash(t *testing.T) {
755	h := newHarness(t, harnessOpts{})
756	h.seed("doomed", domain.VisibilityPrivate, "README.md")
757	root := h.api(adminToken)
758	ctx := h.ctx
759	oldHead := h.bareGit("doomed", "rev-parse", "HEAD")
760
761	// Warm the cat-file process of the old directory.
762	_, err := root.git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: "doomed"}))
763	require.NoError(t, err)
764
765	before := time.Now().Unix()
766	_, err = root.repo.DeleteRepo(ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "doomed"}))
767	require.NoError(t, err)
768
769	assert.NoDirExists(t, h.repoDir("doomed"))
770	matches, err := filepath.Glob(filepath.Join(h.data, "trash", "doomed-*.git"))
771	require.NoError(t, err)
772	require.Len(t, matches, 1)
773	var unix int64
774	_, err = fmt.Sscanf(filepath.Base(matches[0]), "doomed-%d.git", &unix)
775	require.NoError(t, err)
776	assert.GreaterOrEqual(t, unix, before)
777	assert.Equal(t, oldHead, strings.TrimSpace(h.mustGit(matches[0], "rev-parse", "HEAD")), "the data is recoverable by hand")
778
779	for name, call := range map[string]func() error{
780		"get": func() error {
781			_, err := root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "doomed"}))
782			return err
783		},
784		"refs": func() error {
785			_, err := root.git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{Repo: "doomed"}))
786			return err
787		},
788		"delete again": func() error {
789			_, err := root.repo.DeleteRepo(ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "doomed"}))
790			return err
791		},
792	} {
793		assert.Equal(t, "not_found", codeName(call()), name)
794	}
795	list, err := root.repo.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{}))
796	require.NoError(t, err)
797	assert.Empty(t, list.Msg.GetRepos())
798
799	// The name is free again, and the new repository is empty.
800	_, err = root.repo.CreateRepo(ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "doomed"}))
801	require.NoError(t, err)
802	_, err = root.git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: "doomed"}))
803	assert.Equal(t, "not_found", codeName(err), "no commit of the deleted repository survives in a reader process")
804}
805
806// A principal in the base context of the HTTP server does not authenticate
807// requests: requests from the network are judged by their headers.
808func TestNetworkRequestsIgnoreAPrincipalInTheContext(t *testing.T) {
809	h := newHarness(t, harnessOpts{})
810	h.seed("pub", domain.VisibilityPublic, "README.md")
811	h.seed("priv", domain.VisibilityPrivate, "README.md")
812
813	srv := httptest.NewUnstartedServer(h.forge.API.Handler())
814	srv.Config.BaseContext = func(net.Listener) context.Context {
815		return domain.ContextWithPrincipal(h.ctx, domain.Root())
816	}
817	srv.Start()
818	t.Cleanup(srv.Close)
819
820	list := func(secret string) []string {
821		c := newClients(srv.URL, secret)
822		resp, err := c.repo.ListRepos(h.ctx, connect.NewRequest(&pb.ListReposRequest{}))
823		require.NoError(t, err)
824		var out []string
825		for _, r := range resp.Msg.GetRepos() {
826			out = append(out, r.GetName())
827		}
828		return out
829	}
830	assert.Equal(t, []string{"pub"}, list(""), "anonymous by its headers, not root by its context")
831	assert.Equal(t, []string{"priv", "pub"}, list(adminToken))
832	_, err := newClients(srv.URL, "").repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "nope"}))
833	assert.Equal(t, "unauthenticated", codeName(err))
834}

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 2

Tags 44

↑↓ moveenter openesc close