test(cmd,rpc): wait for events with sentinels and long timeouts, and table-test the enum mappers
Negative waits (quiet for 200-300 ms) become sentinel events that must come next; positive waits are 10 s, except TestAC5PushEventWithinASecond which keeps its explicit sub-second assertion. Every domain value maps to a non-UNSPECIFIED value of the contract, and the inverse mappers round-trip.
- commit
- fcf895d4abf14d79bc3e12317a3342319ab8e2da
- author
- brice · · 2026-10-10 11:16 UTC
- parent
- fbcd64e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +205 -56
M cmd/ermite/api_helpers_test.go +0 -13
| @@ -162,19 +162,6 @@ func (s *stream) next(t *testing.T, d time.Duration) *pb.Event { | |||
| 162 | 162 | return nil | |
| 163 | 163 | } | |
| 164 | 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 | 165 | // readBlob reads a whole GetBlob stream and returns its messages. | |
| 179 | 166 | func readBlob(ctx context.Context, c *clients, req *pb.GetBlobRequest) ([]*pb.GetBlobResponse, error) { | |
| 180 | 167 | st, err := c.git.GetBlob(ctx, connect.NewRequest(req)) | |
| @@ -162,19 +162,6 @@ func (s *stream) next(t *testing.T, d time.Duration) *pb.Event { | |||||
| 162 | return nil | 162 | return nil | ||
| 163 | } | 163 | } | ||
| 164 | 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. | 165 | // readBlob reads a whole GetBlob stream and returns its messages. | ||
| 179 | func readBlob(ctx context.Context, c *clients, req *pb.GetBlobRequest) ([]*pb.GetBlobResponse, error) { | 166 | func readBlob(ctx context.Context, c *clients, req *pb.GetBlobRequest) ([]*pb.GetBlobResponse, error) { | ||
| 180 | st, err := c.git.GetBlob(ctx, connect.NewRequest(req)) | 167 | st, err := c.git.GetBlob(ctx, connect.NewRequest(req)) | ||
M cmd/ermite/api_test.go +1 -1
| @@ -815,7 +815,7 @@ func TestInProcessClient(t *testing.T) { | |||
| 815 | 815 | select { | |
| 816 | 816 | case ev := <-got: | |
| 817 | 817 | assert.Equal(t, "d", ev.GetRepoUpdated().GetRepo().GetDescription()) | |
| 818 | - | case <-time.After(5 * time.Second): | |
| 818 | + | case <-time.After(10 * time.Second): | |
| 819 | 819 | t.Fatal("no event through the in-process client") | |
| 820 | 820 | } | |
| 821 | 821 | ||
| @@ -815,7 +815,7 @@ func TestInProcessClient(t *testing.T) { | |||||
| 815 | select { | 815 | select { | ||
| 816 | case ev := <-got: | 816 | case ev := <-got: | ||
| 817 | assert.Equal(t, "d", ev.GetRepoUpdated().GetRepo().GetDescription()) | 817 | assert.Equal(t, "d", ev.GetRepoUpdated().GetRepo().GetDescription()) | ||
| 818 | - | case <-time.After(5 * time.Second): | 818 | + | case <-time.After(10 * time.Second): |
| 819 | t.Fatal("no event through the in-process client") | 819 | t.Fatal("no event through the in-process client") | ||
| 820 | } | 820 | } | ||
| 821 | 821 | ||||
M cmd/ermite/events_test.go +69 -42
| @@ -23,6 +23,10 @@ import ( | |||
| 23 | 23 | "bnema.dev/ermite/internal/domain" | |
| 24 | 24 | ) | |
| 25 | 25 | ||
| 26 | + | // watchWait is how long a test waits for an event that must arrive. Only | |
| 27 | + | // TestAC5PushEventWithinASecond holds the server to a second. | |
| 28 | + | const watchWait = 10 * time.Second | |
| 29 | + | ||
| 26 | 30 | // AC5: a push shows up in Watch within a second, with the ref updates. | |
| 27 | 31 | func TestAC5PushEventWithinASecond(t *testing.T) { | |
| 28 | 32 | h := newHarness(t, harnessOpts{}) | |
| @@ -50,14 +54,14 @@ func TestAC5PushEventWithinASecond(t *testing.T) { | |||
| 50 | 54 | old := up.GetNewId() | |
| 51 | 55 | h.writeFile(wt, "b.txt", []byte("b\n")) | |
| 52 | 56 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 53 | - | up2 := w.next(t, time.Second).GetPushed().GetRefUpdates()[0] | |
| 57 | + | up2 := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] | |
| 54 | 58 | assert.Equal(t, old, up2.GetOldId()) | |
| 55 | 59 | assert.NotEqual(t, old, up2.GetNewId()) | |
| 56 | 60 | ||
| 57 | 61 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp") | |
| 58 | - | w.next(t, time.Second) | |
| 62 | + | w.next(t, watchWait) | |
| 59 | 63 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp") | |
| 60 | - | del := w.next(t, time.Second).GetPushed().GetRefUpdates()[0] | |
| 64 | + | del := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] | |
| 61 | 65 | assert.Equal(t, "refs/heads/tmp", del.GetRef()) | |
| 62 | 66 | assert.NotEmpty(t, del.GetOldId()) | |
| 63 | 67 | assert.Empty(t, del.GetNewId()) | |
| @@ -72,7 +76,7 @@ func TestAC5RepoLifecycleEvents(t *testing.T) { | |||
| 72 | 76 | ||
| 73 | 77 | _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC})) | |
| 74 | 78 | require.NoError(t, err) | |
| 75 | - | created := w.next(t, time.Second).GetRepoCreated().GetRepo() | |
| 79 | + | created := w.next(t, watchWait).GetRepoCreated().GetRepo() | |
| 76 | 80 | require.NotNil(t, created) | |
| 77 | 81 | assert.Equal(t, "life", created.GetName()) | |
| 78 | 82 | assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility()) | |
| @@ -82,20 +86,21 @@ func TestAC5RepoLifecycleEvents(t *testing.T) { | |||
| 82 | 86 | Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |
| 83 | 87 | })) | |
| 84 | 88 | require.NoError(t, err) | |
| 85 | - | updated := w.next(t, time.Second).GetRepoUpdated().GetRepo() | |
| 89 | + | updated := w.next(t, watchWait).GetRepoUpdated().GetRepo() | |
| 86 | 90 | require.NotNil(t, updated) | |
| 87 | 91 | assert.Equal(t, "new", updated.GetDescription()) | |
| 88 | 92 | ||
| 89 | 93 | _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"})) | |
| 90 | 94 | require.NoError(t, err) | |
| 91 | - | assert.Equal(t, "life", w.next(t, time.Second).GetRepoDeleted().GetRepo()) | |
| 95 | + | assert.Equal(t, "life", w.next(t, watchWait).GetRepoDeleted().GetRepo()) | |
| 92 | 96 | ||
| 93 | 97 | // Create on first push publishes RepoCreated once, then the push. | |
| 94 | 98 | wt := h.workTree("main", "a.txt") | |
| 95 | 99 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main") | |
| 96 | - | assert.Equal(t, "fresh", w.next(t, time.Second).GetRepoCreated().GetRepo().GetName()) | |
| 97 | - | assert.Equal(t, "fresh", w.next(t, time.Second).GetPushed().GetRepo()) | |
| 98 | - | w.quiet(t, 200*time.Millisecond) | |
| 100 | + | assert.Equal(t, "fresh", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 101 | + | assert.Equal(t, "fresh", w.next(t, watchWait).GetPushed().GetRepo()) | |
| 102 | + | // Nothing else came in between: the next event is the sentinel. | |
| 103 | + | h.sentinel(t, w, "s-life") | |
| 99 | 104 | } | |
| 100 | 105 | ||
| 101 | 106 | // AC5: resume from an id replays what came after, then goes live. | |
| @@ -112,30 +117,30 @@ func TestAC5Resume(t *testing.T) { | |||
| 112 | 117 | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | |
| 113 | 118 | } | |
| 114 | 119 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 115 | - | ids = append(ids, w.next(t, 2*time.Second).GetId()) | |
| 120 | + | ids = append(ids, w.next(t, watchWait).GetId()) | |
| 116 | 121 | } | |
| 117 | 122 | w.cancel() | |
| 118 | 123 | ||
| 119 | 124 | // The client lost its connection after ids[1]: it gets 2 and 3, then live. | |
| 120 | 125 | r := h.watch(api, ids[1]) | |
| 121 | 126 | for _, want := range ids[2:] { | |
| 122 | - | ev := r.next(t, time.Second) | |
| 127 | + | ev := r.next(t, watchWait) | |
| 123 | 128 | assert.Equal(t, want, ev.GetId()) | |
| 124 | 129 | assert.NotNil(t, ev.GetPushed()) | |
| 125 | 130 | } | |
| 126 | - | r.quiet(t, 200*time.Millisecond) | |
| 131 | + | // Nothing was replayed twice: the next event is the live one, below. | |
| 127 | 132 | h.writeFile(wt, "live.txt", []byte("x\n")) | |
| 128 | 133 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 129 | - | live := r.next(t, time.Second) | |
| 134 | + | live := r.next(t, watchWait) | |
| 130 | 135 | assert.NotNil(t, live.GetPushed()) | |
| 131 | 136 | assert.NotContains(t, ids, live.GetId()) | |
| 132 | 137 | ||
| 133 | 138 | // An id nobody issued is a gap, followed by the oldest buffered event: the | |
| 134 | 139 | // creation of the repository, which came before the first push. | |
| 135 | 140 | g := h.watch(api, "not-an-id") | |
| 136 | - | assert.NotNil(t, g.next(t, time.Second).GetGap()) | |
| 137 | - | assert.Equal(t, "pub", g.next(t, time.Second).GetRepoCreated().GetRepo().GetName()) | |
| 138 | - | assert.Equal(t, ids[0], g.next(t, time.Second).GetId()) | |
| 141 | + | assert.NotNil(t, g.next(t, watchWait).GetGap()) | |
| 142 | + | assert.Equal(t, "pub", g.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 143 | + | assert.Equal(t, ids[0], g.next(t, watchWait).GetId()) | |
| 139 | 144 | } | |
| 140 | 145 | ||
| 141 | 146 | // AC5: a gap when the id fell out of a small ring. | |
| @@ -152,21 +157,21 @@ func TestAC5GapAfterOverflow(t *testing.T) { | |||
| 152 | 157 | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | |
| 153 | 158 | } | |
| 154 | 159 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 155 | - | ids = append(ids, w.next(t, 2*time.Second).GetId()) | |
| 160 | + | ids = append(ids, w.next(t, watchWait).GetId()) | |
| 156 | 161 | } | |
| 157 | 162 | ||
| 158 | 163 | // ids[2] is out of the ring (the last four are ids[4:]). | |
| 159 | 164 | r := h.watch(api, ids[2]) | |
| 160 | - | gap := r.next(t, time.Second) | |
| 165 | + | gap := r.next(t, watchWait) | |
| 161 | 166 | assert.NotNil(t, gap.GetGap()) | |
| 162 | 167 | assert.Empty(t, gap.GetId()) | |
| 163 | 168 | for _, want := range ids[4:] { | |
| 164 | - | assert.Equal(t, want, r.next(t, time.Second).GetId()) | |
| 169 | + | assert.Equal(t, want, r.next(t, watchWait).GetId()) | |
| 165 | 170 | } | |
| 166 | 171 | ||
| 167 | 172 | // The last buffered id still resumes without a gap. | |
| 168 | 173 | ok := h.watch(api, ids[4]) | |
| 169 | - | assert.Equal(t, ids[5], ok.next(t, time.Second).GetId()) | |
| 174 | + | assert.Equal(t, ids[5], ok.next(t, watchWait).GetId()) | |
| 170 | 175 | } | |
| 171 | 176 | ||
| 172 | 177 | // AC5: private events never reach anonymous callers, nor tokens that may not | |
| @@ -198,33 +203,40 @@ func TestAC5NoPrivateEventsToAnonymous(t *testing.T) { | |||
| 198 | 203 | require.NoError(t, err) | |
| 199 | 204 | ||
| 200 | 205 | // The reader of priv sees its push and its update, and pub's push. | |
| 201 | - | assert.Equal(t, "priv", rd.next(t, time.Second).GetPushed().GetRepo()) | |
| 202 | - | assert.Equal(t, "pub", rd.next(t, time.Second).GetPushed().GetRepo(), "pub is public") | |
| 203 | - | assert.Equal(t, "secret plans", rd.next(t, time.Second).GetRepoUpdated().GetRepo().GetDescription()) | |
| 206 | + | assert.Equal(t, "priv", rd.next(t, watchWait).GetPushed().GetRepo()) | |
| 207 | + | assert.Equal(t, "pub", rd.next(t, watchWait).GetPushed().GetRepo(), "pub is public") | |
| 208 | + | assert.Equal(t, "secret plans", rd.next(t, watchWait).GetRepoUpdated().GetRepo().GetDescription()) | |
| 204 | 209 | // Root sees everything in order. | |
| 205 | - | assert.Equal(t, "priv", root.next(t, time.Second).GetPushed().GetRepo()) | |
| 206 | - | assert.Equal(t, "pub", root.next(t, time.Second).GetPushed().GetRepo()) | |
| 207 | - | assert.Equal(t, "priv", root.next(t, time.Second).GetRepoUpdated().GetRepo().GetName()) | |
| 210 | + | assert.Equal(t, "priv", root.next(t, watchWait).GetPushed().GetRepo()) | |
| 211 | + | assert.Equal(t, "pub", root.next(t, watchWait).GetPushed().GetRepo()) | |
| 212 | + | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoUpdated().GetRepo().GetName()) | |
| 208 | 213 | ||
| 209 | 214 | // Deleting reaches those who could read the repository before, not others. | |
| 210 | 215 | _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"})) | |
| 211 | 216 | require.NoError(t, err) | |
| 212 | - | assert.Equal(t, "priv", rd.next(t, time.Second).GetRepoDeleted().GetRepo()) | |
| 213 | - | assert.Equal(t, "priv", root.next(t, time.Second).GetRepoDeleted().GetRepo()) | |
| 217 | + | assert.Equal(t, "priv", rd.next(t, watchWait).GetRepoDeleted().GetRepo()) | |
| 218 | + | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoDeleted().GetRepo()) | |
| 214 | 219 | ||
| 215 | 220 | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | |
| 216 | - | ev := w.next(t, time.Second) | |
| 221 | + | ev := w.next(t, watchWait) | |
| 217 | 222 | assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name) | |
| 218 | - | w.quiet(t, 300*time.Millisecond) | |
| 223 | + | } | |
| 224 | + | // Nothing about priv came before the sentinel, for either of them. | |
| 225 | + | _, err = h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("sentinel"), Visibility: domain.VisibilityPublic}) | |
| 226 | + | require.NoError(t, err) | |
| 227 | + | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | |
| 228 | + | assert.Equal(t, "sentinel", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName(), name) | |
| 219 | 229 | } | |
| 220 | 230 | ||
| 221 | 231 | // Resuming does not replay private events to anonymous either: after a | |
| 222 | - | // gap, the creation and the push of pub, nothing about priv. | |
| 232 | + | // gap, the creation and the push of pub, the sentinels the watchers above | |
| 233 | + | // saw, and nothing about priv. | |
| 223 | 234 | r := h.watch(h.api(""), "bogus") | |
| 224 | - | assert.NotNil(t, r.next(t, time.Second).GetGap()) | |
| 225 | - | assert.Equal(t, "pub", r.next(t, time.Second).GetRepoCreated().GetRepo().GetName()) | |
| 226 | - | assert.Equal(t, "pub", r.next(t, time.Second).GetPushed().GetRepo()) | |
| 227 | - | r.quiet(t, 300*time.Millisecond) | |
| 235 | + | assert.NotNil(t, r.next(t, watchWait).GetGap()) | |
| 236 | + | assert.Equal(t, "pub", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 237 | + | assert.Equal(t, "pub", r.next(t, watchWait).GetPushed().GetRepo()) | |
| 238 | + | assert.Equal(t, "sentinel", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 239 | + | h.sentinel(t, r, "s-resumed") | |
| 228 | 240 | } | |
| 229 | 241 | ||
| 230 | 242 | // AC5: a repo topic only carries that repository; a revoked token cannot | |
| @@ -238,8 +250,11 @@ func TestAC5RepoTopic(t *testing.T) { | |||
| 238 | 250 | wt := h.workTree("main", "x.txt") | |
| 239 | 251 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main") | |
| 240 | 252 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | |
| 241 | - | assert.Equal(t, "a", w.next(t, time.Second).GetPushed().GetRepo()) | |
| 242 | - | w.quiet(t, 300*time.Millisecond) | |
| 253 | + | assert.Equal(t, "a", w.next(t, watchWait).GetPushed().GetRepo()) | |
| 254 | + | // The push to b was not delivered: the next event is the next push to a. | |
| 255 | + | h.writeFile(wt, "y.txt", []byte("y\n")) | |
| 256 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | |
| 257 | + | assert.Equal(t, h.bareGit("a", "rev-parse", "main"), w.next(t, watchWait).GetPushed().GetRefUpdates()[0].GetNewId()) | |
| 243 | 258 | } | |
| 244 | 259 | ||
| 245 | 260 | // The stream ends cleanly when the client cancels, and Watch counts go back to zero. | |
| @@ -250,7 +265,7 @@ func TestWatchEndsOnCancel(t *testing.T) { | |||
| 250 | 265 | w.cancel() | |
| 251 | 266 | select { | |
| 252 | 267 | case <-w.done: | |
| 253 | - | case <-time.After(5 * time.Second): | |
| 268 | + | case <-time.After(5 * watchWait): | |
| 254 | 269 | t.Fatal("the stream did not end") | |
| 255 | 270 | } | |
| 256 | 271 | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond) | |
| @@ -306,13 +321,13 @@ func TestServeShutdownEndsWatchStreams(t *testing.T) { | |||
| 306 | 321 | select { | |
| 307 | 322 | case err := <-ended: | |
| 308 | 323 | assert.NoError(t, err, "the stream ends cleanly") | |
| 309 | - | case <-time.After(15 * time.Second): | |
| 324 | + | case <-time.After(15 * watchWait): | |
| 310 | 325 | t.Fatalf("the stream did not end\n%s", logs.String()) | |
| 311 | 326 | } | |
| 312 | 327 | select { | |
| 313 | 328 | case err := <-exited: | |
| 314 | 329 | require.NoError(t, err, "%s", logs.String()) | |
| 315 | - | case <-time.After(15 * time.Second): | |
| 330 | + | case <-time.After(15 * watchWait): | |
| 316 | 331 | t.Fatalf("the server did not exit\n%s", logs.String()) | |
| 317 | 332 | } | |
| 318 | 333 | assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout") | |
| @@ -332,7 +347,7 @@ func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | |||
| 332 | 347 | w := h.watch(h.api(created.Secret), "") | |
| 333 | 348 | wt := h.workTree("main", "a.txt") | |
| 334 | 349 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 335 | - | assert.Equal(t, "priv", w.next(t, 5*time.Second).GetPushed().GetRepo(), "the token may read priv") | |
| 350 | + | assert.Equal(t, "priv", w.next(t, 5*watchWait).GetPushed().GetRepo(), "the token may read priv") | |
| 336 | 351 | ||
| 337 | 352 | require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID)) | |
| 338 | 353 | h.writeFile(wt, "b.txt", []byte("b\n")) | |
| @@ -340,7 +355,7 @@ func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | |||
| 340 | 355 | ||
| 341 | 356 | select { | |
| 342 | 357 | case <-w.done: | |
| 343 | - | case <-time.After(5 * time.Second): | |
| 358 | + | case <-time.After(5 * watchWait): | |
| 344 | 359 | t.Fatal("the stream of a revoked token did not end") | |
| 345 | 360 | } | |
| 346 | 361 | assert.Equal(t, "unauthenticated", codeName(w.err)) | |
| @@ -374,3 +389,15 @@ func TestWatchLimits(t *testing.T) { | |||
| 374 | 389 | require.NoError(t, err) | |
| 375 | 390 | _ = st.Close() | |
| 376 | 391 | } | |
| 392 | + | ||
| 393 | + | // sentinel proves that nothing reached the stream since the last event it | |
| 394 | + | // returned: it creates a public repository, which every watcher of "home" may | |
| 395 | + | // see, and requires its RepoCreated to be the next event. Events arrive in the | |
| 396 | + | // order they were published. | |
| 397 | + | func (h *harness) sentinel(t *testing.T, s *stream, name string) { | |
| 398 | + | t.Helper() | |
| 399 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | |
| 400 | + | require.NoError(t, err) | |
| 401 | + | ev := s.next(t, watchWait) | |
| 402 | + | assert.Equal(t, name, ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel: %v", ev) | |
| 403 | + | } | |
| @@ -23,6 +23,10 @@ import ( | |||||
| 23 | "bnema.dev/ermite/internal/domain" | 23 | "bnema.dev/ermite/internal/domain" | ||
| 24 | ) | 24 | ) | ||
| 25 | 25 | ||||
| 26 | + | // watchWait is how long a test waits for an event that must arrive. Only | |||
| 27 | + | // TestAC5PushEventWithinASecond holds the server to a second. | |||
| 28 | + | const watchWait = 10 * time.Second | |||
| 29 | + | ||||
| 26 | // AC5: a push shows up in Watch within a second, with the ref updates. | 30 | // AC5: a push shows up in Watch within a second, with the ref updates. | ||
| 27 | func TestAC5PushEventWithinASecond(t *testing.T) { | 31 | func TestAC5PushEventWithinASecond(t *testing.T) { | ||
| 28 | h := newHarness(t, harnessOpts{}) | 32 | h := newHarness(t, harnessOpts{}) | ||
| @@ -50,14 +54,14 @@ func TestAC5PushEventWithinASecond(t *testing.T) { | |||||
| 50 | old := up.GetNewId() | 54 | old := up.GetNewId() | ||
| 51 | h.writeFile(wt, "b.txt", []byte("b\n")) | 55 | h.writeFile(wt, "b.txt", []byte("b\n")) | ||
| 52 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | 56 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | ||
| 53 | - | up2 := w.next(t, time.Second).GetPushed().GetRefUpdates()[0] | 57 | + | up2 := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] |
| 54 | assert.Equal(t, old, up2.GetOldId()) | 58 | assert.Equal(t, old, up2.GetOldId()) | ||
| 55 | assert.NotEqual(t, old, up2.GetNewId()) | 59 | assert.NotEqual(t, old, up2.GetNewId()) | ||
| 56 | 60 | ||||
| 57 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp") | 61 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp") | ||
| 58 | - | w.next(t, time.Second) | 62 | + | w.next(t, watchWait) |
| 59 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp") | 63 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp") | ||
| 60 | - | del := w.next(t, time.Second).GetPushed().GetRefUpdates()[0] | 64 | + | del := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] |
| 61 | assert.Equal(t, "refs/heads/tmp", del.GetRef()) | 65 | assert.Equal(t, "refs/heads/tmp", del.GetRef()) | ||
| 62 | assert.NotEmpty(t, del.GetOldId()) | 66 | assert.NotEmpty(t, del.GetOldId()) | ||
| 63 | assert.Empty(t, del.GetNewId()) | 67 | assert.Empty(t, del.GetNewId()) | ||
| @@ -72,7 +76,7 @@ func TestAC5RepoLifecycleEvents(t *testing.T) { | |||||
| 72 | 76 | ||||
| 73 | _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC})) | 77 | _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC})) | ||
| 74 | require.NoError(t, err) | 78 | require.NoError(t, err) | ||
| 75 | - | created := w.next(t, time.Second).GetRepoCreated().GetRepo() | 79 | + | created := w.next(t, watchWait).GetRepoCreated().GetRepo() |
| 76 | require.NotNil(t, created) | 80 | require.NotNil(t, created) | ||
| 77 | assert.Equal(t, "life", created.GetName()) | 81 | assert.Equal(t, "life", created.GetName()) | ||
| 78 | assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility()) | 82 | assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility()) | ||
| @@ -82,20 +86,21 @@ func TestAC5RepoLifecycleEvents(t *testing.T) { | |||||
| 82 | Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | 86 | Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | ||
| 83 | })) | 87 | })) | ||
| 84 | require.NoError(t, err) | 88 | require.NoError(t, err) | ||
| 85 | - | updated := w.next(t, time.Second).GetRepoUpdated().GetRepo() | 89 | + | updated := w.next(t, watchWait).GetRepoUpdated().GetRepo() |
| 86 | require.NotNil(t, updated) | 90 | require.NotNil(t, updated) | ||
| 87 | assert.Equal(t, "new", updated.GetDescription()) | 91 | assert.Equal(t, "new", updated.GetDescription()) | ||
| 88 | 92 | ||||
| 89 | _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"})) | 93 | _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"})) | ||
| 90 | require.NoError(t, err) | 94 | require.NoError(t, err) | ||
| 91 | - | assert.Equal(t, "life", w.next(t, time.Second).GetRepoDeleted().GetRepo()) | 95 | + | assert.Equal(t, "life", w.next(t, watchWait).GetRepoDeleted().GetRepo()) |
| 92 | 96 | ||||
| 93 | // Create on first push publishes RepoCreated once, then the push. | 97 | // Create on first push publishes RepoCreated once, then the push. | ||
| 94 | wt := h.workTree("main", "a.txt") | 98 | wt := h.workTree("main", "a.txt") | ||
| 95 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main") | 99 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main") | ||
| 96 | - | assert.Equal(t, "fresh", w.next(t, time.Second).GetRepoCreated().GetRepo().GetName()) | 100 | + | assert.Equal(t, "fresh", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) |
| 97 | - | assert.Equal(t, "fresh", w.next(t, time.Second).GetPushed().GetRepo()) | 101 | + | assert.Equal(t, "fresh", w.next(t, watchWait).GetPushed().GetRepo()) |
| 98 | - | w.quiet(t, 200*time.Millisecond) | 102 | + | // Nothing else came in between: the next event is the sentinel. |
| 103 | + | h.sentinel(t, w, "s-life") | |||
| 99 | } | 104 | } | ||
| 100 | 105 | ||||
| 101 | // AC5: resume from an id replays what came after, then goes live. | 106 | // AC5: resume from an id replays what came after, then goes live. | ||
| @@ -112,30 +117,30 @@ func TestAC5Resume(t *testing.T) { | |||||
| 112 | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | 117 | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | ||
| 113 | } | 118 | } | ||
| 114 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | 119 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | ||
| 115 | - | ids = append(ids, w.next(t, 2*time.Second).GetId()) | 120 | + | ids = append(ids, w.next(t, watchWait).GetId()) |
| 116 | } | 121 | } | ||
| 117 | w.cancel() | 122 | w.cancel() | ||
| 118 | 123 | ||||
| 119 | // The client lost its connection after ids[1]: it gets 2 and 3, then live. | 124 | // The client lost its connection after ids[1]: it gets 2 and 3, then live. | ||
| 120 | r := h.watch(api, ids[1]) | 125 | r := h.watch(api, ids[1]) | ||
| 121 | for _, want := range ids[2:] { | 126 | for _, want := range ids[2:] { | ||
| 122 | - | ev := r.next(t, time.Second) | 127 | + | ev := r.next(t, watchWait) |
| 123 | assert.Equal(t, want, ev.GetId()) | 128 | assert.Equal(t, want, ev.GetId()) | ||
| 124 | assert.NotNil(t, ev.GetPushed()) | 129 | assert.NotNil(t, ev.GetPushed()) | ||
| 125 | } | 130 | } | ||
| 126 | - | r.quiet(t, 200*time.Millisecond) | 131 | + | // Nothing was replayed twice: the next event is the live one, below. |
| 127 | h.writeFile(wt, "live.txt", []byte("x\n")) | 132 | h.writeFile(wt, "live.txt", []byte("x\n")) | ||
| 128 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | 133 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | ||
| 129 | - | live := r.next(t, time.Second) | 134 | + | live := r.next(t, watchWait) |
| 130 | assert.NotNil(t, live.GetPushed()) | 135 | assert.NotNil(t, live.GetPushed()) | ||
| 131 | assert.NotContains(t, ids, live.GetId()) | 136 | assert.NotContains(t, ids, live.GetId()) | ||
| 132 | 137 | ||||
| 133 | // An id nobody issued is a gap, followed by the oldest buffered event: the | 138 | // An id nobody issued is a gap, followed by the oldest buffered event: the | ||
| 134 | // creation of the repository, which came before the first push. | 139 | // creation of the repository, which came before the first push. | ||
| 135 | g := h.watch(api, "not-an-id") | 140 | g := h.watch(api, "not-an-id") | ||
| 136 | - | assert.NotNil(t, g.next(t, time.Second).GetGap()) | 141 | + | assert.NotNil(t, g.next(t, watchWait).GetGap()) |
| 137 | - | assert.Equal(t, "pub", g.next(t, time.Second).GetRepoCreated().GetRepo().GetName()) | 142 | + | assert.Equal(t, "pub", g.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) |
| 138 | - | assert.Equal(t, ids[0], g.next(t, time.Second).GetId()) | 143 | + | assert.Equal(t, ids[0], g.next(t, watchWait).GetId()) |
| 139 | } | 144 | } | ||
| 140 | 145 | ||||
| 141 | // AC5: a gap when the id fell out of a small ring. | 146 | // AC5: a gap when the id fell out of a small ring. | ||
| @@ -152,21 +157,21 @@ func TestAC5GapAfterOverflow(t *testing.T) { | |||||
| 152 | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | 157 | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | ||
| 153 | } | 158 | } | ||
| 154 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | 159 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | ||
| 155 | - | ids = append(ids, w.next(t, 2*time.Second).GetId()) | 160 | + | ids = append(ids, w.next(t, watchWait).GetId()) |
| 156 | } | 161 | } | ||
| 157 | 162 | ||||
| 158 | // ids[2] is out of the ring (the last four are ids[4:]). | 163 | // ids[2] is out of the ring (the last four are ids[4:]). | ||
| 159 | r := h.watch(api, ids[2]) | 164 | r := h.watch(api, ids[2]) | ||
| 160 | - | gap := r.next(t, time.Second) | 165 | + | gap := r.next(t, watchWait) |
| 161 | assert.NotNil(t, gap.GetGap()) | 166 | assert.NotNil(t, gap.GetGap()) | ||
| 162 | assert.Empty(t, gap.GetId()) | 167 | assert.Empty(t, gap.GetId()) | ||
| 163 | for _, want := range ids[4:] { | 168 | for _, want := range ids[4:] { | ||
| 164 | - | assert.Equal(t, want, r.next(t, time.Second).GetId()) | 169 | + | assert.Equal(t, want, r.next(t, watchWait).GetId()) |
| 165 | } | 170 | } | ||
| 166 | 171 | ||||
| 167 | // The last buffered id still resumes without a gap. | 172 | // The last buffered id still resumes without a gap. | ||
| 168 | ok := h.watch(api, ids[4]) | 173 | ok := h.watch(api, ids[4]) | ||
| 169 | - | assert.Equal(t, ids[5], ok.next(t, time.Second).GetId()) | 174 | + | assert.Equal(t, ids[5], ok.next(t, watchWait).GetId()) |
| 170 | } | 175 | } | ||
| 171 | 176 | ||||
| 172 | // AC5: private events never reach anonymous callers, nor tokens that may not | 177 | // AC5: private events never reach anonymous callers, nor tokens that may not | ||
| @@ -198,33 +203,40 @@ func TestAC5NoPrivateEventsToAnonymous(t *testing.T) { | |||||
| 198 | require.NoError(t, err) | 203 | require.NoError(t, err) | ||
| 199 | 204 | ||||
| 200 | // The reader of priv sees its push and its update, and pub's push. | 205 | // The reader of priv sees its push and its update, and pub's push. | ||
| 201 | - | assert.Equal(t, "priv", rd.next(t, time.Second).GetPushed().GetRepo()) | 206 | + | assert.Equal(t, "priv", rd.next(t, watchWait).GetPushed().GetRepo()) |
| 202 | - | assert.Equal(t, "pub", rd.next(t, time.Second).GetPushed().GetRepo(), "pub is public") | 207 | + | assert.Equal(t, "pub", rd.next(t, watchWait).GetPushed().GetRepo(), "pub is public") |
| 203 | - | assert.Equal(t, "secret plans", rd.next(t, time.Second).GetRepoUpdated().GetRepo().GetDescription()) | 208 | + | assert.Equal(t, "secret plans", rd.next(t, watchWait).GetRepoUpdated().GetRepo().GetDescription()) |
| 204 | // Root sees everything in order. | 209 | // Root sees everything in order. | ||
| 205 | - | assert.Equal(t, "priv", root.next(t, time.Second).GetPushed().GetRepo()) | 210 | + | assert.Equal(t, "priv", root.next(t, watchWait).GetPushed().GetRepo()) |
| 206 | - | assert.Equal(t, "pub", root.next(t, time.Second).GetPushed().GetRepo()) | 211 | + | assert.Equal(t, "pub", root.next(t, watchWait).GetPushed().GetRepo()) |
| 207 | - | assert.Equal(t, "priv", root.next(t, time.Second).GetRepoUpdated().GetRepo().GetName()) | 212 | + | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoUpdated().GetRepo().GetName()) |
| 208 | 213 | ||||
| 209 | // Deleting reaches those who could read the repository before, not others. | 214 | // Deleting reaches those who could read the repository before, not others. | ||
| 210 | _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"})) | 215 | _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"})) | ||
| 211 | require.NoError(t, err) | 216 | require.NoError(t, err) | ||
| 212 | - | assert.Equal(t, "priv", rd.next(t, time.Second).GetRepoDeleted().GetRepo()) | 217 | + | assert.Equal(t, "priv", rd.next(t, watchWait).GetRepoDeleted().GetRepo()) |
| 213 | - | assert.Equal(t, "priv", root.next(t, time.Second).GetRepoDeleted().GetRepo()) | 218 | + | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoDeleted().GetRepo()) |
| 214 | 219 | ||||
| 215 | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | 220 | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | ||
| 216 | - | ev := w.next(t, time.Second) | 221 | + | ev := w.next(t, watchWait) |
| 217 | assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name) | 222 | assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name) | ||
| 218 | - | w.quiet(t, 300*time.Millisecond) | 223 | + | } |
| 224 | + | // Nothing about priv came before the sentinel, for either of them. | |||
| 225 | + | _, err = h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("sentinel"), Visibility: domain.VisibilityPublic}) | |||
| 226 | + | require.NoError(t, err) | |||
| 227 | + | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | |||
| 228 | + | assert.Equal(t, "sentinel", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName(), name) | |||
| 219 | } | 229 | } | ||
| 220 | 230 | ||||
| 221 | // Resuming does not replay private events to anonymous either: after a | 231 | // Resuming does not replay private events to anonymous either: after a | ||
| 222 | - | // gap, the creation and the push of pub, nothing about priv. | 232 | + | // gap, the creation and the push of pub, the sentinels the watchers above |
| 233 | + | // saw, and nothing about priv. | |||
| 223 | r := h.watch(h.api(""), "bogus") | 234 | r := h.watch(h.api(""), "bogus") | ||
| 224 | - | assert.NotNil(t, r.next(t, time.Second).GetGap()) | 235 | + | assert.NotNil(t, r.next(t, watchWait).GetGap()) |
| 225 | - | assert.Equal(t, "pub", r.next(t, time.Second).GetRepoCreated().GetRepo().GetName()) | 236 | + | assert.Equal(t, "pub", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) |
| 226 | - | assert.Equal(t, "pub", r.next(t, time.Second).GetPushed().GetRepo()) | 237 | + | assert.Equal(t, "pub", r.next(t, watchWait).GetPushed().GetRepo()) |
| 227 | - | r.quiet(t, 300*time.Millisecond) | 238 | + | assert.Equal(t, "sentinel", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) |
| 239 | + | h.sentinel(t, r, "s-resumed") | |||
| 228 | } | 240 | } | ||
| 229 | 241 | ||||
| 230 | // AC5: a repo topic only carries that repository; a revoked token cannot | 242 | // AC5: a repo topic only carries that repository; a revoked token cannot | ||
| @@ -238,8 +250,11 @@ func TestAC5RepoTopic(t *testing.T) { | |||||
| 238 | wt := h.workTree("main", "x.txt") | 250 | wt := h.workTree("main", "x.txt") | ||
| 239 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main") | 251 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main") | ||
| 240 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | 252 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | ||
| 241 | - | assert.Equal(t, "a", w.next(t, time.Second).GetPushed().GetRepo()) | 253 | + | assert.Equal(t, "a", w.next(t, watchWait).GetPushed().GetRepo()) |
| 242 | - | w.quiet(t, 300*time.Millisecond) | 254 | + | // The push to b was not delivered: the next event is the next push to a. |
| 255 | + | h.writeFile(wt, "y.txt", []byte("y\n")) | |||
| 256 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | |||
| 257 | + | assert.Equal(t, h.bareGit("a", "rev-parse", "main"), w.next(t, watchWait).GetPushed().GetRefUpdates()[0].GetNewId()) | |||
| 243 | } | 258 | } | ||
| 244 | 259 | ||||
| 245 | // The stream ends cleanly when the client cancels, and Watch counts go back to zero. | 260 | // The stream ends cleanly when the client cancels, and Watch counts go back to zero. | ||
| @@ -250,7 +265,7 @@ func TestWatchEndsOnCancel(t *testing.T) { | |||||
| 250 | w.cancel() | 265 | w.cancel() | ||
| 251 | select { | 266 | select { | ||
| 252 | case <-w.done: | 267 | case <-w.done: | ||
| 253 | - | case <-time.After(5 * time.Second): | 268 | + | case <-time.After(5 * watchWait): |
| 254 | t.Fatal("the stream did not end") | 269 | t.Fatal("the stream did not end") | ||
| 255 | } | 270 | } | ||
| 256 | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond) | 271 | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond) | ||
| @@ -306,13 +321,13 @@ func TestServeShutdownEndsWatchStreams(t *testing.T) { | |||||
| 306 | select { | 321 | select { | ||
| 307 | case err := <-ended: | 322 | case err := <-ended: | ||
| 308 | assert.NoError(t, err, "the stream ends cleanly") | 323 | assert.NoError(t, err, "the stream ends cleanly") | ||
| 309 | - | case <-time.After(15 * time.Second): | 324 | + | case <-time.After(15 * watchWait): |
| 310 | t.Fatalf("the stream did not end\n%s", logs.String()) | 325 | t.Fatalf("the stream did not end\n%s", logs.String()) | ||
| 311 | } | 326 | } | ||
| 312 | select { | 327 | select { | ||
| 313 | case err := <-exited: | 328 | case err := <-exited: | ||
| 314 | require.NoError(t, err, "%s", logs.String()) | 329 | require.NoError(t, err, "%s", logs.String()) | ||
| 315 | - | case <-time.After(15 * time.Second): | 330 | + | case <-time.After(15 * watchWait): |
| 316 | t.Fatalf("the server did not exit\n%s", logs.String()) | 331 | t.Fatalf("the server did not exit\n%s", logs.String()) | ||
| 317 | } | 332 | } | ||
| 318 | assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout") | 333 | assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout") | ||
| @@ -332,7 +347,7 @@ func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | |||||
| 332 | w := h.watch(h.api(created.Secret), "") | 347 | w := h.watch(h.api(created.Secret), "") | ||
| 333 | wt := h.workTree("main", "a.txt") | 348 | wt := h.workTree("main", "a.txt") | ||
| 334 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | 349 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | ||
| 335 | - | assert.Equal(t, "priv", w.next(t, 5*time.Second).GetPushed().GetRepo(), "the token may read priv") | 350 | + | assert.Equal(t, "priv", w.next(t, 5*watchWait).GetPushed().GetRepo(), "the token may read priv") |
| 336 | 351 | ||||
| 337 | require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID)) | 352 | require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID)) | ||
| 338 | h.writeFile(wt, "b.txt", []byte("b\n")) | 353 | h.writeFile(wt, "b.txt", []byte("b\n")) | ||
| @@ -340,7 +355,7 @@ func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | |||||
| 340 | 355 | ||||
| 341 | select { | 356 | select { | ||
| 342 | case <-w.done: | 357 | case <-w.done: | ||
| 343 | - | case <-time.After(5 * time.Second): | 358 | + | case <-time.After(5 * watchWait): |
| 344 | t.Fatal("the stream of a revoked token did not end") | 359 | t.Fatal("the stream of a revoked token did not end") | ||
| 345 | } | 360 | } | ||
| 346 | assert.Equal(t, "unauthenticated", codeName(w.err)) | 361 | assert.Equal(t, "unauthenticated", codeName(w.err)) | ||
| @@ -374,3 +389,15 @@ func TestWatchLimits(t *testing.T) { | |||||
| 374 | require.NoError(t, err) | 389 | require.NoError(t, err) | ||
| 375 | _ = st.Close() | 390 | _ = st.Close() | ||
| 376 | } | 391 | } | ||
| 392 | + | ||||
| 393 | + | // sentinel proves that nothing reached the stream since the last event it | |||
| 394 | + | // returned: it creates a public repository, which every watcher of "home" may | |||
| 395 | + | // see, and requires its RepoCreated to be the next event. Events arrive in the | |||
| 396 | + | // order they were published. | |||
| 397 | + | func (h *harness) sentinel(t *testing.T, s *stream, name string) { | |||
| 398 | + | t.Helper() | |||
| 399 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | |||
| 400 | + | require.NoError(t, err) | |||
| 401 | + | ev := s.next(t, watchWait) | |||
| 402 | + | assert.Equal(t, name, ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel: %v", ev) | |||
| 403 | + | } | |||
A internal/adapters/rpc/convert_test.go +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Every value of the domain maps to a real value of the contract, never to | |
| 16 | + | // UNSPECIFIED, and the mappers that have an inverse round-trip. A value added | |
| 17 | + | // to the domain and forgotten here would reach clients as "not set". | |
| 18 | + | func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |
| 19 | + | t.Run("visibility", func(t *testing.T) { | |
| 20 | + | for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} { | |
| 21 | + | got := visibilityToPB(v) | |
| 22 | + | assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v) | |
| 23 | + | back, err := visibilityFromPB(got) | |
| 24 | + | require.NoError(t, err, v) | |
| 25 | + | assert.Equal(t, v, back) | |
| 26 | + | } | |
| 27 | + | assert.Equal(t, pb.Visibility_VISIBILITY_UNSPECIFIED, visibilityToPB("")) | |
| 28 | + | _, err := visibilityFromPB(pb.Visibility(99)) | |
| 29 | + | assert.ErrorIs(t, err, domain.ErrInvalidVisibility) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | t.Run("scope", func(t *testing.T) { | |
| 33 | + | for _, s := range domain.AllScopes() { | |
| 34 | + | got, ok := scopeToPB[s] | |
| 35 | + | require.True(t, ok, "scope %s has no counterpart in the contract", s) | |
| 36 | + | assert.NotEqual(t, pb.Scope_SCOPE_UNSPECIFIED, got, s) | |
| 37 | + | back, err := scopeFromPB(got) | |
| 38 | + | require.NoError(t, err, s) | |
| 39 | + | assert.Equal(t, s, back) | |
| 40 | + | } | |
| 41 | + | _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED) | |
| 42 | + | assert.ErrorIs(t, err, domain.ErrInvalidScope) | |
| 43 | + | // Every scope of the contract is a scope of the domain. | |
| 44 | + | for v := range pb.Scope_name { | |
| 45 | + | if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED { | |
| 46 | + | continue | |
| 47 | + | } | |
| 48 | + | _, err := scopeFromPB(pb.Scope(v)) | |
| 49 | + | assert.NoError(t, err, pb.Scope(v)) | |
| 50 | + | } | |
| 51 | + | }) | |
| 52 | + | ||
| 53 | + | t.Run("ref kind", func(t *testing.T) { | |
| 54 | + | for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} { | |
| 55 | + | got := refKindToPB(k) | |
| 56 | + | assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k) | |
| 57 | + | back, err := refKindFromPB(got) | |
| 58 | + | require.NoError(t, err, k) | |
| 59 | + | assert.Equal(t, k, back) | |
| 60 | + | } | |
| 61 | + | _, err := refKindFromPB(pb.RefKind_REF_KIND_UNSPECIFIED) | |
| 62 | + | assert.ErrorIs(t, err, domain.ErrInvalidRefKind) | |
| 63 | + | }) | |
| 64 | + | ||
| 65 | + | t.Run("entry kind", func(t *testing.T) { | |
| 66 | + | seen := map[pb.EntryKind]domain.EntryKind{} | |
| 67 | + | for _, k := range []domain.EntryKind{ | |
| 68 | + | domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule, | |
| 69 | + | } { | |
| 70 | + | got := entryKindToPB(k) | |
| 71 | + | assert.NotEqual(t, pb.EntryKind_ENTRY_KIND_UNSPECIFIED, got, k) | |
| 72 | + | assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got]) | |
| 73 | + | seen[got] = k | |
| 74 | + | } | |
| 75 | + | }) | |
| 76 | + | ||
| 77 | + | t.Run("file status", func(t *testing.T) { | |
| 78 | + | seen := map[pb.FileStatus]domain.FileStatus{} | |
| 79 | + | for _, s := range []domain.FileStatus{ | |
| 80 | + | domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged, | |
| 81 | + | } { | |
| 82 | + | got := fileStatusToPB(s) | |
| 83 | + | assert.NotEqual(t, pb.FileStatus_FILE_STATUS_UNSPECIFIED, got, s) | |
| 84 | + | assert.NotContains(t, seen, got, "%s and %s share a value", s, seen[got]) | |
| 85 | + | seen[got] = s | |
| 86 | + | } | |
| 87 | + | }) | |
| 88 | + | ||
| 89 | + | t.Run("line kind", func(t *testing.T) { | |
| 90 | + | seen := map[pb.LineKind]domain.LineKind{} | |
| 91 | + | for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} { | |
| 92 | + | got := lineKindToPB(k) | |
| 93 | + | assert.NotEqual(t, pb.LineKind_LINE_KIND_UNSPECIFIED, got, k) | |
| 94 | + | assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got]) | |
| 95 | + | seen[got] = k | |
| 96 | + | } | |
| 97 | + | }) | |
| 98 | + | ||
| 99 | + | // Every value of the contract except UNSPECIFIED is produced by some value | |
| 100 | + | // of the domain: nothing in the contract is unreachable. | |
| 101 | + | t.Run("the contract has no value the domain cannot produce", func(t *testing.T) { | |
| 102 | + | produced := func(names map[int32]string, from func(v int32) bool) { | |
| 103 | + | for v, n := range names { | |
| 104 | + | if v == 0 { | |
| 105 | + | continue | |
| 106 | + | } | |
| 107 | + | assert.True(t, from(v), "%s is never produced", n) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | produced(pb.EntryKind_name, func(v int32) bool { | |
| 111 | + | for _, k := range []domain.EntryKind{domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule} { | |
| 112 | + | if entryKindToPB(k) == pb.EntryKind(v) { | |
| 113 | + | return true | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | return false | |
| 117 | + | }) | |
| 118 | + | produced(pb.FileStatus_name, func(v int32) bool { | |
| 119 | + | for _, k := range []domain.FileStatus{domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged} { | |
| 120 | + | if fileStatusToPB(k) == pb.FileStatus(v) { | |
| 121 | + | return true | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | return false | |
| 125 | + | }) | |
| 126 | + | produced(pb.LineKind_name, func(v int32) bool { | |
| 127 | + | for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} { | |
| 128 | + | if lineKindToPB(k) == pb.LineKind(v) { | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | return false | |
| 133 | + | }) | |
| 134 | + | }) | |
| 135 | + | } | |
| @@ -0,0 +1,135 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package rpc | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Every value of the domain maps to a real value of the contract, never to | |||
| 16 | + | // UNSPECIFIED, and the mappers that have an inverse round-trip. A value added | |||
| 17 | + | // to the domain and forgotten here would reach clients as "not set". | |||
| 18 | + | func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||
| 19 | + | t.Run("visibility", func(t *testing.T) { | |||
| 20 | + | for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} { | |||
| 21 | + | got := visibilityToPB(v) | |||
| 22 | + | assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v) | |||
| 23 | + | back, err := visibilityFromPB(got) | |||
| 24 | + | require.NoError(t, err, v) | |||
| 25 | + | assert.Equal(t, v, back) | |||
| 26 | + | } | |||
| 27 | + | assert.Equal(t, pb.Visibility_VISIBILITY_UNSPECIFIED, visibilityToPB("")) | |||
| 28 | + | _, err := visibilityFromPB(pb.Visibility(99)) | |||
| 29 | + | assert.ErrorIs(t, err, domain.ErrInvalidVisibility) | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | t.Run("scope", func(t *testing.T) { | |||
| 33 | + | for _, s := range domain.AllScopes() { | |||
| 34 | + | got, ok := scopeToPB[s] | |||
| 35 | + | require.True(t, ok, "scope %s has no counterpart in the contract", s) | |||
| 36 | + | assert.NotEqual(t, pb.Scope_SCOPE_UNSPECIFIED, got, s) | |||
| 37 | + | back, err := scopeFromPB(got) | |||
| 38 | + | require.NoError(t, err, s) | |||
| 39 | + | assert.Equal(t, s, back) | |||
| 40 | + | } | |||
| 41 | + | _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED) | |||
| 42 | + | assert.ErrorIs(t, err, domain.ErrInvalidScope) | |||
| 43 | + | // Every scope of the contract is a scope of the domain. | |||
| 44 | + | for v := range pb.Scope_name { | |||
| 45 | + | if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED { | |||
| 46 | + | continue | |||
| 47 | + | } | |||
| 48 | + | _, err := scopeFromPB(pb.Scope(v)) | |||
| 49 | + | assert.NoError(t, err, pb.Scope(v)) | |||
| 50 | + | } | |||
| 51 | + | }) | |||
| 52 | + | ||||
| 53 | + | t.Run("ref kind", func(t *testing.T) { | |||
| 54 | + | for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} { | |||
| 55 | + | got := refKindToPB(k) | |||
| 56 | + | assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k) | |||
| 57 | + | back, err := refKindFromPB(got) | |||
| 58 | + | require.NoError(t, err, k) | |||
| 59 | + | assert.Equal(t, k, back) | |||
| 60 | + | } | |||
| 61 | + | _, err := refKindFromPB(pb.RefKind_REF_KIND_UNSPECIFIED) | |||
| 62 | + | assert.ErrorIs(t, err, domain.ErrInvalidRefKind) | |||
| 63 | + | }) | |||
| 64 | + | ||||
| 65 | + | t.Run("entry kind", func(t *testing.T) { | |||
| 66 | + | seen := map[pb.EntryKind]domain.EntryKind{} | |||
| 67 | + | for _, k := range []domain.EntryKind{ | |||
| 68 | + | domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule, | |||
| 69 | + | } { | |||
| 70 | + | got := entryKindToPB(k) | |||
| 71 | + | assert.NotEqual(t, pb.EntryKind_ENTRY_KIND_UNSPECIFIED, got, k) | |||
| 72 | + | assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got]) | |||
| 73 | + | seen[got] = k | |||
| 74 | + | } | |||
| 75 | + | }) | |||
| 76 | + | ||||
| 77 | + | t.Run("file status", func(t *testing.T) { | |||
| 78 | + | seen := map[pb.FileStatus]domain.FileStatus{} | |||
| 79 | + | for _, s := range []domain.FileStatus{ | |||
| 80 | + | domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged, | |||
| 81 | + | } { | |||
| 82 | + | got := fileStatusToPB(s) | |||
| 83 | + | assert.NotEqual(t, pb.FileStatus_FILE_STATUS_UNSPECIFIED, got, s) | |||
| 84 | + | assert.NotContains(t, seen, got, "%s and %s share a value", s, seen[got]) | |||
| 85 | + | seen[got] = s | |||
| 86 | + | } | |||
| 87 | + | }) | |||
| 88 | + | ||||
| 89 | + | t.Run("line kind", func(t *testing.T) { | |||
| 90 | + | seen := map[pb.LineKind]domain.LineKind{} | |||
| 91 | + | for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} { | |||
| 92 | + | got := lineKindToPB(k) | |||
| 93 | + | assert.NotEqual(t, pb.LineKind_LINE_KIND_UNSPECIFIED, got, k) | |||
| 94 | + | assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got]) | |||
| 95 | + | seen[got] = k | |||
| 96 | + | } | |||
| 97 | + | }) | |||
| 98 | + | ||||
| 99 | + | // Every value of the contract except UNSPECIFIED is produced by some value | |||
| 100 | + | // of the domain: nothing in the contract is unreachable. | |||
| 101 | + | t.Run("the contract has no value the domain cannot produce", func(t *testing.T) { | |||
| 102 | + | produced := func(names map[int32]string, from func(v int32) bool) { | |||
| 103 | + | for v, n := range names { | |||
| 104 | + | if v == 0 { | |||
| 105 | + | continue | |||
| 106 | + | } | |||
| 107 | + | assert.True(t, from(v), "%s is never produced", n) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | produced(pb.EntryKind_name, func(v int32) bool { | |||
| 111 | + | for _, k := range []domain.EntryKind{domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule} { | |||
| 112 | + | if entryKindToPB(k) == pb.EntryKind(v) { | |||
| 113 | + | return true | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | return false | |||
| 117 | + | }) | |||
| 118 | + | produced(pb.FileStatus_name, func(v int32) bool { | |||
| 119 | + | for _, k := range []domain.FileStatus{domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged} { | |||
| 120 | + | if fileStatusToPB(k) == pb.FileStatus(v) { | |||
| 121 | + | return true | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | return false | |||
| 125 | + | }) | |||
| 126 | + | produced(pb.LineKind_name, func(v int32) bool { | |||
| 127 | + | for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} { | |||
| 128 | + | if lineKindToPB(k) == pb.LineKind(v) { | |||
| 129 | + | return true | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | return false | |||
| 133 | + | }) | |||
| 134 | + | }) | |||
| 135 | + | } | |||