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", "commit_signatures"}, 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}