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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||