detached 6a0af58

test(rpc): cover access, tokens, blobs, trash, default branch and events through Connect

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

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 45

↑↓ moveenter openesc close