1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6 "context"
7 "errors"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 "google.golang.org/protobuf/proto"
16
17 kv "bnema.dev/ermite-kv"
18
19 storagepb "bnema.dev/ermite/gen/ermite/storage"
20 "bnema.dev/ermite/internal/adapters/kvstore"
21 "bnema.dev/ermite/internal/domain"
22)
23
24func newMR(t *testing.T, repo domain.RepoName, title string) domain.MergeRequest {
25 t.Helper()
26 ti, err := domain.ParseMRTitle(title)
27 require.NoError(t, err)
28 desc, err := domain.ParsePlainText("why\nand how")
29 require.NoError(t, err)
30 return domain.MergeRequest{
31 Repo: repo, Title: ti, Description: desc, Source: "feat/x", Target: "main",
32 State: domain.MRStateOpen, Author: "agent", CreatedAt: created, UpdatedAt: created,
33 }
34}
35
36func oid(c byte) domain.ObjectID {
37 id, err := domain.ParseObjectID(strings.Repeat(string(c), 40))
38 if err != nil {
39 panic(err)
40 }
41 return id
42}
43
44// repoWithMRs opens a store holding repository "proj".
45func repoWithMRs(t *testing.T) (*kvstore.Store, *kvstore.MergeRequests, domain.RepoName) {
46 t.Helper()
47 s := openStore(t, t.TempDir())
48 n := name(t, "proj")
49 require.NoError(t, s.Create(context.Background(), domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
50 return s, s.MergeRequests(), n
51}
52
53func TestMergeRequestNumbersAreSequentialPerRepository(t *testing.T) {
54 ctx := context.Background()
55 s, mrs, n := repoWithMRs(t)
56 other := name(t, "other")
57 require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
58
59 for want := int64(1); want <= 3; want++ {
60 got, err := mrs.Create(ctx, newMR(t, n, "t"))
61 require.NoError(t, err)
62 assert.Equal(t, want, got.Number)
63 }
64 got, err := mrs.Create(ctx, newMR(t, other, "t"))
65 require.NoError(t, err)
66 assert.Equal(t, int64(1), got.Number, "each repository counts on its own")
67
68 _, err = mrs.Create(ctx, newMR(t, name(t, "ghost"), "t"))
69 assert.ErrorIs(t, err, domain.ErrNotFound, "no merge request for a missing repository")
70}
71
72func TestMergeRequestRoundTrip(t *testing.T) {
73 ctx := context.Background()
74 _, mrs, n := repoWithMRs(t)
75 in := newMR(t, n, "Ajoute « ça »")
76 in.HeadID, in.BaseID, in.MergedCommitID = oid('a'), oid('b'), oid('c')
77 in.MergeMode = domain.MergeModeSquash
78 in.State = domain.MRStateMerged
79 in.CommentSeq = 4
80 created1, err := mrs.Create(ctx, in)
81 require.NoError(t, err)
82
83 got, err := mrs.Get(ctx, n, created1.Number)
84 require.NoError(t, err)
85 assert.NotZero(t, got.Version)
86 in.Number, in.Version = 1, got.Version
87 assert.Equal(t, in, got)
88
89 _, err = mrs.Get(ctx, n, 2)
90 assert.ErrorIs(t, err, domain.ErrNotFound)
91 _, err = mrs.Get(ctx, n, 0)
92 assert.ErrorIs(t, err, domain.ErrNotFound)
93 _, err = mrs.Get(ctx, n, -3)
94 assert.ErrorIs(t, err, domain.ErrNotFound)
95}
96
97func TestMergeRequestAutoMergeRoundTrip(t *testing.T) {
98 ctx := context.Background()
99 _, mrs, n := repoWithMRs(t)
100
101 set := newMR(t, n, "Auto")
102 set.AutoMerge = &domain.AutoMerge{
103 Mode: domain.MergeModeSquash, HeadID: oid('d'),
104 EnabledBy: domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "tok1"},
105 EnablerLabel: "ci-bot", EnabledAt: created,
106 }
107 created1, err := mrs.Create(ctx, set)
108 require.NoError(t, err)
109 got, err := mrs.Get(ctx, n, created1.Number)
110 require.NoError(t, err)
111 require.NotNil(t, got.AutoMerge)
112 assert.Equal(t, *set.AutoMerge, *got.AutoMerge)
113 assert.False(t, got.AutoMerged)
114
115 unset := newMR(t, n, "Merged by auto")
116 unset.State = domain.MRStateMerged
117 unset.AutoMerged = true
118 created2, err := mrs.Create(ctx, unset)
119 require.NoError(t, err)
120 got, err = mrs.Get(ctx, n, created2.Number)
121 require.NoError(t, err)
122 assert.Nil(t, got.AutoMerge)
123 assert.True(t, got.AutoMerged)
124}
125
126func TestMergeRequestListIsNewestFirstAndPerRepository(t *testing.T) {
127 ctx := context.Background()
128 s, mrs, n := repoWithMRs(t)
129 // "proj-2" shares the prefix "proj": its keys must not leak into proj's.
130 sib := name(t, "proj-2")
131 require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
132 for range 12 { // beyond 10: numbers sort numerically, not as text
133 _, err := mrs.Create(ctx, newMR(t, n, "t"))
134 require.NoError(t, err)
135 }
136 _, err := mrs.Create(ctx, newMR(t, sib, "sibling"))
137 require.NoError(t, err)
138
139 list, err := mrs.List(ctx, n)
140 require.NoError(t, err)
141 require.Len(t, list, 12)
142 for i, mr := range list {
143 assert.Equal(t, int64(12-i), mr.Number)
144 assert.Equal(t, n, mr.Repo)
145 }
146 list, err = mrs.List(ctx, name(t, "none"))
147 require.NoError(t, err)
148 assert.Empty(t, list)
149}
150
151func TestConcurrentCreateNeverSharesANumber(t *testing.T) {
152 ctx := context.Background()
153 _, mrs, n := repoWithMRs(t)
154 const workers = 8
155 var (
156 wg sync.WaitGroup
157 mu sync.Mutex
158 numbers []int64
159 )
160 for range workers {
161 wg.Go(func() {
162 got, err := mrs.Create(ctx, newMR(t, n, "t"))
163 if assert.NoError(t, err) {
164 mu.Lock()
165 numbers = append(numbers, got.Number)
166 mu.Unlock()
167 }
168 })
169 }
170 wg.Wait()
171 assert.ElementsMatch(t, []int64{1, 2, 3, 4, 5, 6, 7, 8}, numbers)
172}
173
174func TestMergeRequestUpdateNeedsTheVersionItWasReadAt(t *testing.T) {
175 ctx := context.Background()
176 _, mrs, n := repoWithMRs(t)
177 first, err := mrs.Create(ctx, newMR(t, n, "t"))
178 require.NoError(t, err)
179
180 a, err := mrs.Get(ctx, n, first.Number)
181 require.NoError(t, err)
182 b := a
183 a.State = domain.MRStateClosed
184 require.NoError(t, mrs.Update(ctx, a))
185
186 b.State = domain.MRStateMerged
187 assert.ErrorIs(t, mrs.Update(ctx, b), domain.ErrConflict, "b was read before a wrote")
188
189 got, err := mrs.Get(ctx, n, first.Number)
190 require.NoError(t, err)
191 assert.Equal(t, domain.MRStateClosed, got.State)
192
193 gone := got
194 gone.Number = 99
195 assert.ErrorIs(t, mrs.Update(ctx, gone), domain.ErrNotFound)
196 fresh := newMR(t, n, "never read")
197 fresh.Number = first.Number
198 assert.ErrorIs(t, mrs.Update(ctx, fresh), domain.ErrConflict, "an unread request has no version")
199}
200
201func newComment(t *testing.T, id int64, text string) domain.Comment {
202 t.Helper()
203 pt, err := domain.ParsePlainText(text)
204 require.NoError(t, err)
205 return domain.Comment{
206 ID: id, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideNew,
207 CommitID: oid('d'), Text: pt, CreatedAt: created.Add(time.Duration(id) * time.Minute),
208 }
209}
210
211func TestComments(t *testing.T) {
212 ctx := context.Background()
213 _, mrs, n := repoWithMRs(t)
214 first, err := mrs.Create(ctx, newMR(t, n, "t"))
215 require.NoError(t, err)
216
217 // Twelve comments: ids sort numerically too.
218 for id := int64(1); id <= 12; id++ {
219 mr, err := mrs.Get(ctx, n, first.Number)
220 require.NoError(t, err)
221 mr.CommentSeq = id
222 c := newComment(t, id, "note")
223 if id == 2 {
224 c.Path, c.Line, c.Side = "", 0, domain.SideNone
225 }
226 require.NoError(t, mrs.SaveComment(ctx, mr, c))
227 }
228 list, err := mrs.ListComments(ctx, n, first.Number)
229 require.NoError(t, err)
230 require.Len(t, list, 12)
231 for i, c := range list {
232 assert.Equal(t, int64(i+1), c.ID, "oldest first")
233 }
234 assert.True(t, list[1].IsGeneral())
235 assert.Equal(t, domain.SideNone, list[1].Side)
236 assert.Equal(t, domain.SideNew, list[0].Side)
237
238 mr, err := mrs.Get(ctx, n, first.Number)
239 require.NoError(t, err)
240 assert.Equal(t, int64(12), mr.CommentSeq)
241
242 c, err := mrs.GetComment(ctx, n, first.Number, 3)
243 require.NoError(t, err)
244 assert.NotZero(t, c.Version)
245 assert.Equal(t, "note", c.Text.String())
246 assert.Equal(t, oid('d'), c.CommitID)
247 for _, id := range []int64{0, 13, -1} {
248 _, err = mrs.GetComment(ctx, n, first.Number, id)
249 assert.ErrorIs(t, err, domain.ErrNotFound)
250 }
251
252 // Another merge request's comments are not listed.
253 second, err := mrs.Create(ctx, newMR(t, n, "t2"))
254 require.NoError(t, err)
255 list, err = mrs.ListComments(ctx, n, second.Number)
256 require.NoError(t, err)
257 assert.Empty(t, list)
258}
259
260func TestSaveCommentPreconditions(t *testing.T) {
261 ctx := context.Background()
262 _, mrs, n := repoWithMRs(t)
263 first, err := mrs.Create(ctx, newMR(t, n, "t"))
264 require.NoError(t, err)
265 mr, err := mrs.Get(ctx, n, first.Number)
266 require.NoError(t, err)
267
268 mr.CommentSeq = 1
269 require.NoError(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "one")))
270
271 // The same id again, from the same stale read: the request moved on.
272 assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "dup")), domain.ErrConflict)
273
274 // A fresh read of the request but a new comment with an existing id.
275 mr, err = mrs.Get(ctx, n, first.Number)
276 require.NoError(t, err)
277 assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "dup")), domain.ErrConflict, "a new comment must not overwrite")
278
279 // Resolving needs the comment version.
280 c, err := mrs.GetComment(ctx, n, first.Number, 1)
281 require.NoError(t, err)
282 stale := c
283 c.Resolved = true
284 require.NoError(t, mrs.SaveComment(ctx, mr, c))
285 mr, err = mrs.Get(ctx, n, first.Number)
286 require.NoError(t, err)
287 stale.Resolved = false
288 assert.ErrorIs(t, mrs.SaveComment(ctx, mr, stale), domain.ErrConflict)
289
290 // An unread request cannot be saved with.
291 assert.ErrorIs(t, mrs.SaveComment(ctx, newMR(t, n, "x"), newComment(t, 2, "n")), domain.ErrConflict)
292}
293
294func TestReadPosition(t *testing.T) {
295 ctx := context.Background()
296 _, mrs, n := repoWithMRs(t)
297 got, err := mrs.GetRead(ctx, n, 1)
298 require.NoError(t, err)
299 assert.True(t, got.IsZero())
300
301 require.NoError(t, mrs.SetRead(ctx, n, 1, oid('a')))
302 require.NoError(t, mrs.SetRead(ctx, n, 1, oid('b')))
303 got, err = mrs.GetRead(ctx, n, 1)
304 require.NoError(t, err)
305 assert.Equal(t, oid('b'), got)
306 got, err = mrs.GetRead(ctx, n, 2)
307 require.NoError(t, err)
308 assert.True(t, got.IsZero())
309 assert.Error(t, mrs.SetRead(ctx, n, 1, domain.ObjectID{}))
310}
311
312// TestDeleteDropsTheMergeRequestsOfTheRepository: deleting a repository drops
313// its merge requests, comments, read positions and number sequence, and only
314// its own; a repository created again under the name starts at !1.
315func TestDeleteDropsTheMergeRequestsOfTheRepository(t *testing.T) {
316 ctx := context.Background()
317 s, mrs, n := repoWithMRs(t)
318 sib := name(t, "proj-2")
319 require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
320
321 for _, repo := range []domain.RepoName{n, sib} {
322 mr, err := mrs.Create(ctx, newMR(t, repo, "t"))
323 require.NoError(t, err)
324 mr.CommentSeq = 1
325 require.NoError(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "note")))
326 require.NoError(t, mrs.SetRead(ctx, repo, mr.Number, oid('a')))
327 }
328
329 require.NoError(t, s.Delete(ctx, n))
330
331 list, err := mrs.List(ctx, n)
332 require.NoError(t, err)
333 assert.Empty(t, list)
334 comments, err := mrs.ListComments(ctx, n, 1)
335 require.NoError(t, err)
336 assert.Empty(t, comments)
337 read, err := mrs.GetRead(ctx, n, 1)
338 require.NoError(t, err)
339 assert.True(t, read.IsZero())
340
341 list, err = mrs.List(ctx, sib)
342 require.NoError(t, err)
343 require.Len(t, list, 1, "a repository whose name starts like the deleted one keeps its requests")
344 comments, err = mrs.ListComments(ctx, sib, 1)
345 require.NoError(t, err)
346 assert.Len(t, comments, 1)
347
348 require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
349 again, err := mrs.Create(ctx, newMR(t, n, "fresh"))
350 require.NoError(t, err)
351 assert.Equal(t, int64(1), again.Number)
352}
353
354// TestWritesAfterDeleteLeaveNothing: a comment or a read position written for
355// a repository that was deleted meanwhile is refused, so that a repository
356// created again under the name does not inherit it (GO-002).
357func TestWritesAfterDeleteLeaveNothing(t *testing.T) {
358 ctx := context.Background()
359 s, mrs, n := repoWithMRs(t)
360 mr, err := mrs.Create(ctx, newMR(t, n, "t"))
361 require.NoError(t, err)
362
363 require.NoError(t, s.Delete(ctx, n))
364
365 mr.CommentSeq = 1
366 assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "late")), domain.ErrNotFound)
367 assert.ErrorIs(t, mrs.SetRead(ctx, n, mr.Number, oid('a')), domain.ErrNotFound)
368 _, err = mrs.Create(ctx, newMR(t, n, "late"))
369 assert.ErrorIs(t, err, domain.ErrNotFound)
370
371 require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPublic}))
372 assertNoMergeRequestKeys(t, mrs, n)
373}
374
375func assertNoMergeRequestKeys(t *testing.T, mrs *kvstore.MergeRequests, n domain.RepoName) {
376 t.Helper()
377 ctx := context.Background()
378 list, err := mrs.List(ctx, n)
379 require.NoError(t, err)
380 assert.Empty(t, list, "merge requests")
381 comments, err := mrs.ListComments(ctx, n, 1)
382 require.NoError(t, err)
383 assert.Empty(t, comments, "comments")
384 read, err := mrs.GetRead(ctx, n, 1)
385 require.NoError(t, err)
386 assert.True(t, read.IsZero(), "read position")
387}
388
389// TestDeleteRacingWithWrites: writes racing with Delete either land before it
390// and are deleted, or are refused (not found after it, or a conflict when the
391// retries run out under this contention); nothing is left for a repository
392// created again under the name. Delete itself may report a conflict under the
393// same contention and is then called again.
394func TestDeleteRacingWithWrites(t *testing.T) {
395 ctx := context.Background()
396 for round := range 20 {
397 s, mrs, n := repoWithMRs(t)
398 mr, err := mrs.Create(ctx, newMR(t, n, "t"))
399 require.NoError(t, err)
400
401 var wg sync.WaitGroup
402 for w := range 4 {
403 wg.Add(1)
404 go func() {
405 defer wg.Done()
406 for i := range 30 {
407 if w%2 == 0 {
408 err := mrs.SetRead(ctx, n, mr.Number, oid('a'))
409 if err != nil {
410 assert.True(t, errors.Is(err, domain.ErrConflict) || errors.Is(err, domain.ErrNotFound), "round %d: %v", round, err)
411 return
412 }
413 continue
414 }
415 cur, err := mrs.Get(ctx, n, mr.Number)
416 if err != nil {
417 return
418 }
419 cur.CommentSeq = int64(w*1000 + i + 1)
420 err = mrs.SaveComment(ctx, cur, newComment(t, cur.CommentSeq, "note"))
421 if err != nil && !assert.True(t, errors.Is(err, domain.ErrConflict) || errors.Is(err, domain.ErrNotFound), "round %d: %v", round, err) {
422 return
423 }
424 }
425 }()
426 }
427 // Delete gives up with a conflict when the writers keep rewriting the
428 // repository key for deleteTries listings in a row; it is documented as
429 // safe to call again, as a caller does.
430 var delErr error
431 for range 20 {
432 if delErr = s.Delete(ctx, n); !errors.Is(delErr, domain.ErrConflict) {
433 break
434 }
435 }
436 wg.Wait()
437 require.NoError(t, delErr)
438
439 require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPublic}))
440 assertNoMergeRequestKeys(t, mrs, n)
441 again, err := mrs.Create(ctx, newMR(t, n, "fresh"))
442 require.NoError(t, err)
443 assert.Equal(t, int64(1), again.Number)
444 }
445}
446
447func TestDeleteManyMergeRequests(t *testing.T) {
448 ctx := context.Background()
449 s, mrs, n := repoWithMRs(t)
450 for range 1100 {
451 _, err := mrs.Create(ctx, newMR(t, n, "t"))
452 require.NoError(t, err)
453 }
454 require.NoError(t, s.Delete(ctx, n))
455 list, err := mrs.List(ctx, n)
456 require.NoError(t, err)
457 assert.Empty(t, list)
458 _, err = s.Get(ctx, n)
459 assert.ErrorIs(t, err, domain.ErrNotFound)
460}
461
462func TestRepoSettings(t *testing.T) {
463 ctx := context.Background()
464 s := openStore(t, t.TempDir())
465 n := name(t, "proj")
466
467 // Created without settings: they are stored explicitly, with the defaults.
468 require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
469 got, err := s.Get(ctx, n)
470 require.NoError(t, err)
471 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
472
473 got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}
474 _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
475 require.NoError(t, err)
476 again, err := s.Get(ctx, n)
477 require.NoError(t, err)
478 assert.Equal(t, got.Settings, again.Settings)
479
480 got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{"rebase"}}
481 _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
482 assert.ErrorIs(t, err, domain.ErrInvalid)
483}
484
485func TestRepoSettingsKeepAllowAutoMerge(t *testing.T) {
486 ctx := context.Background()
487 s := openStore(t, t.TempDir())
488 n := name(t, "proj")
489 require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
490 got, err := s.Get(ctx, n)
491 require.NoError(t, err)
492 assert.False(t, got.Settings.AllowAutoMerge)
493
494 settings := domain.DefaultRepoSettings()
495 settings.AllowAutoMerge = true
496 _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = settings; return true, nil })
497 require.NoError(t, err)
498 got, err = s.Get(ctx, n)
499 require.NoError(t, err)
500 assert.Equal(t, settings, got.Settings)
501}
502
503// TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before
504// merge requests existed has no settings field.
505func TestRepoWithoutStoredSettingsReadsTheDefaults(t *testing.T) {
506 ctx := context.Background()
507 dir := t.TempDir()
508 s, err := kvstore.Open(ctx, dir)
509 require.NoError(t, err)
510 require.NoError(t, s.Close(ctx))
511
512 // Write a legacy value straight into the database.
513 db, err := kv.Open(ctx, dir, kv.Options{})
514 require.NoError(t, err)
515 legacy, err := proto.Marshal(&storagepb.Repo{Name: "legacy", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC})
516 require.NoError(t, err)
517 var b kv.Batch
518 b.Put([]byte("repo/legacy"), legacy)
519 _, err = db.Apply(ctx, &b)
520 require.NoError(t, err)
521 require.NoError(t, db.Close(ctx))
522
523 s = openStore(t, dir)
524 got, err := s.Get(ctx, name(t, "legacy"))
525 require.NoError(t, err)
526 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
527 list, err := s.List(ctx)
528 require.NoError(t, err)
529 require.Len(t, list, 1)
530 assert.Equal(t, domain.DefaultRepoSettings(), list[0].Settings)
531}
532
533func TestSigningSeed(t *testing.T) {
534 ctx := context.Background()
535 dir := t.TempDir()
536 s := openStore(t, dir)
537 calls := 0
538 gen := func() ([]byte, error) {
539 calls++
540 return []byte(strings.Repeat(string(rune('a'+calls)), kvstore.SigningSeedLen)), nil
541 }
542
543 first, err := s.SigningSeed(ctx, gen, created)
544 require.NoError(t, err)
545 require.Len(t, first, kvstore.SigningSeedLen)
546 second, err := s.SigningSeed(ctx, gen, created)
547 require.NoError(t, err)
548 assert.Equal(t, first, second, "generated once")
549 assert.Equal(t, 1, calls)
550
551 // It survives a restart.
552 require.NoError(t, s.Close(ctx))
553 s2 := openStore(t, dir)
554 third, err := s2.SigningSeed(ctx, gen, created)
555 require.NoError(t, err)
556 assert.Equal(t, first, third)
557 assert.Equal(t, 1, calls)
558}
559
560func TestSigningSeedConcurrentFirstStart(t *testing.T) {
561 ctx := context.Background()
562 s := openStore(t, t.TempDir())
563 var (
564 wg sync.WaitGroup
565 mu sync.Mutex
566 seeds [][]byte
567 n byte
568 )
569 for range 6 {
570 wg.Go(func() {
571 seed, err := s.SigningSeed(ctx, func() ([]byte, error) {
572 mu.Lock()
573 defer mu.Unlock()
574 n++
575 return []byte(strings.Repeat(string(rune('A'+n)), kvstore.SigningSeedLen)), nil
576 }, created)
577 if assert.NoError(t, err) {
578 mu.Lock()
579 seeds = append(seeds, seed)
580 mu.Unlock()
581 }
582 })
583 }
584 wg.Wait()
585 for _, seed := range seeds {
586 assert.Equal(t, seeds[0], seed, "every caller gets the winner's seed")
587 }
588}
589
590func TestSigningSeedRejectsBadGenerators(t *testing.T) {
591 ctx := context.Background()
592 s := openStore(t, t.TempDir())
593 _, err := s.SigningSeed(ctx, func() ([]byte, error) { return []byte("short"), nil }, created)
594 require.Error(t, err)
595 assert.NotContains(t, err.Error(), "short")
596 _, err = s.SigningSeed(ctx, func() ([]byte, error) { return nil, assert.AnError }, created)
597 require.ErrorIs(t, err, assert.AnError)
598}
599
600func TestNoteFieldsRoundTrip(t *testing.T) {
601 ctx := context.Background()
602 _, store, name := repoWithMRs(t)
603 mr, err := store.Create(ctx, newMR(t, name, "notes"))
604 require.NoError(t, err)
605 for id := int64(1); id <= 2; id++ {
606 mr, err = store.Get(ctx, name, mr.Number)
607 require.NoError(t, err)
608 c := newComment(t, id, "note")
609 if id == 2 {
610 c.ReplyTo = 1
611 c.StartLine = 1
612 c.Verdict = domain.VerdictApprove
613 c.EditedAt = created
614 c.Deleted = true
615 }
616 mr.CommentSeq = id
617 require.NoError(t, store.SaveComment(ctx, mr, c))
618 got, err := store.GetComment(ctx, name, mr.Number, id)
619 require.NoError(t, err)
620 c.Version = got.Version
621 assert.Equal(t, c, got)
622 }
623}
624
625// TestStoredOldDefaultModesGainMerge: settings written before merge commits
626// (no merge_modes_v2) with exactly {fast-forward, squash} read with merge too;
627// the same set written by this build is an explicit choice and stays.
628func TestStoredOldDefaultModesGainMerge(t *testing.T) {
629 ctx := context.Background()
630 dir := t.TempDir()
631 s, err := kvstore.Open(ctx, dir)
632 require.NoError(t, err)
633 require.NoError(t, s.Close(ctx))
634
635 put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) {
636 val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC,
637 Settings: &storagepb.RepoSettings{AllowedMergeModes: modes, FailingChecksBlock: true}})
638 require.NoError(t, err)
639 var b kv.Batch
640 b.Put([]byte("repo/"+repo), val)
641 _, err = db.Apply(ctx, &b)
642 require.NoError(t, err)
643 }
644 db, err := kv.Open(ctx, dir, kv.Options{})
645 require.NoError(t, err)
646 put(db, "old", storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, storagepb.MergeMode_MERGE_MODE_SQUASH)
647 put(db, "single", storagepb.MergeMode_MERGE_MODE_SQUASH)
648 require.NoError(t, db.Close(ctx))
649
650 s = openStore(t, dir)
651 old, err := s.Get(ctx, name(t, "old"))
652 require.NoError(t, err)
653 assert.Equal(t, domain.MergeModes(), old.Settings.AllowedMergeModes)
654 single, err := s.Get(ctx, name(t, "single"))
655 require.NoError(t, err)
656 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, single.Settings.AllowedMergeModes)
657
658 // An explicit {ff, squash} written now is kept.
659 _, err = s.Mutate(ctx, old.Name, func(r *domain.Repo) (bool, error) {
660 r.Settings.AllowedMergeModes = []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}
661 return true, nil
662 })
663 require.NoError(t, err)
664 again, err := s.Get(ctx, name(t, "old"))
665 require.NoError(t, err)
666 assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, again.Settings.AllowedMergeModes)
667}
668
669func mergeability(mr domain.MergeRequest) domain.Mergeability {
670 return domain.Mergeability{
671 Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityConflict,
672 TargetID: oid('a'), HeadID: oid('b'), Behind: 3, BehindCapped: true,
673 ConflictPaths: []string{"a.txt", "b/c.txt"}, ConflictPathsTruncated: true, CheckedAt: created,
674 }
675}
676
677func TestPutMergeabilityCAS(t *testing.T) {
678 ctx := context.Background()
679 _, mrs, n := repoWithMRs(t)
680 mr, err := mrs.Create(ctx, newMR(t, n, "t"))
681 require.NoError(t, err)
682
683 _, err = mrs.GetMergeability(ctx, n, mr.Number)
684 assert.ErrorIs(t, err, domain.ErrNotFound)
685
686 m := mergeability(mr)
687 require.NoError(t, mrs.PutMergeability(ctx, m))
688 assert.ErrorIs(t, mrs.PutMergeability(ctx, m), domain.ErrConflict, "version 0 needs it absent")
689
690 got, err := mrs.GetMergeability(ctx, n, mr.Number)
691 require.NoError(t, err)
692 assert.NotZero(t, got.Version)
693 m.Version = got.Version
694 assert.Equal(t, m, got)
695
696 next := got
697 next.State = domain.MergeabilityUpToDate
698 require.NoError(t, mrs.PutMergeability(ctx, next))
699 assert.ErrorIs(t, mrs.PutMergeability(ctx, next), domain.ErrConflict, "stale version")
700
701 require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number))
702 require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number), "absent is fine")
703 _, err = mrs.GetMergeability(ctx, n, mr.Number)
704 assert.ErrorIs(t, err, domain.ErrNotFound)
705
706 missing := mergeability(mr)
707 missing.Number = 99
708 assert.ErrorIs(t, mrs.PutMergeability(ctx, missing), domain.ErrNotFound)
709
710 checking := mergeability(mr)
711 checking.State = domain.MergeabilityChecking
712 assert.ErrorIs(t, mrs.PutMergeability(ctx, checking), domain.ErrInvalid)
713}
714
715func TestMergeabilityRefusedAndDroppedWhenNotOpen(t *testing.T) {
716 for _, state := range []domain.MRState{domain.MRStateMerged, domain.MRStateClosed} {
717 ctx := context.Background()
718 _, mrs, n := repoWithMRs(t)
719 mr, err := mrs.Create(ctx, newMR(t, n, "t"))
720 require.NoError(t, err)
721 require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
722
723 cur, err := mrs.Get(ctx, n, mr.Number)
724 require.NoError(t, err)
725 cur.State = state
726 require.NoError(t, mrs.Update(ctx, cur))
727 _, err = mrs.GetMergeability(ctx, n, mr.Number)
728 assert.ErrorIs(t, err, domain.ErrNotFound, state)
729
730 assert.ErrorIs(t, mrs.PutMergeability(ctx, mergeability(mr)), domain.ErrNotFound, "a late worker never resurrects it: %s", state)
731 }
732}
733
734func TestDeleteWipesMergeability(t *testing.T) {
735 ctx := context.Background()
736 s, mrs, n := repoWithMRs(t)
737 mr, err := mrs.Create(ctx, newMR(t, n, "t"))
738 require.NoError(t, err)
739 require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
740 require.NoError(t, s.Delete(ctx, n))
741 _, err = mrs.GetMergeability(ctx, n, mr.Number)
742 assert.ErrorIs(t, err, domain.ErrNotFound)
743}