1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6 "bytes"
7 "context"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17)
18
19func newPending(t *testing.T, repo domain.RepoName, title, author string, at time.Time) domain.PendingIssue {
20 t.Helper()
21 id, err := domain.NewPendingIssueID()
22 require.NoError(t, err)
23 ti, err := domain.ParseIssueTitle(title)
24 require.NoError(t, err)
25 body, err := domain.ParseIssueBody("details")
26 require.NoError(t, err)
27 return domain.PendingIssue{
28 ID: id, Repo: repo, Title: ti, Body: body, Author: author, CreatedAt: at,
29 IPHash: bytes.Repeat([]byte{7}, domain.IPHashLen),
30 }
31}
32
33func TestPendingIssuePutGetListCount(t *testing.T) {
34 ctx := context.Background()
35 s, _, n := repoWithMRs(t)
36 other := name(t, "other")
37 require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
38 ps := s.PendingIssues()
39
40 a := newPending(t, n, "a", "Alex", created.Add(time.Minute))
41 b := newPending(t, other, "b", "", created.Add(2*time.Minute))
42 c := newPending(t, n, "c", "", created.Add(3*time.Minute))
43 for _, p := range []domain.PendingIssue{c, a, b} {
44 require.NoError(t, ps.Put(ctx, p))
45 }
46 require.ErrorIs(t, ps.Put(ctx, a), domain.ErrConflict, "an id is used once")
47 require.ErrorIs(t, ps.Put(ctx, newPending(t, name(t, "ghost"), "g", "", created)), domain.ErrNotFound)
48
49 got, err := ps.Get(ctx, n, a.ID)
50 require.NoError(t, err)
51 assert.Equal(t, a.Title, got.Title)
52 assert.Equal(t, "Alex", got.Author)
53 assert.Equal(t, a.IPHash, got.IPHash)
54 assert.True(t, got.CreatedAt.Equal(a.CreatedAt))
55 assert.NotZero(t, got.Version)
56 _, err = ps.Get(ctx, other, a.ID)
57 assert.ErrorIs(t, err, domain.ErrNotFound, "ids are per repository")
58
59 all, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{})
60 require.NoError(t, err)
61 require.Len(t, all.Issues, 3)
62 assert.Equal(t, []domain.PendingIssueID{a.ID, b.ID, c.ID}, []domain.PendingIssueID{all.Issues[0].ID, all.Issues[1].ID, all.Issues[2].ID}, "oldest first")
63
64 mine, err := ps.List(ctx, n, ports.ListPendingParams{})
65 require.NoError(t, err)
66 assert.Len(t, mine.Issues, 2)
67
68 p1, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2})
69 require.NoError(t, err)
70 require.Len(t, p1.Issues, 2)
71 require.NotEmpty(t, p1.NextPageToken)
72 p2, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2, PageToken: p1.NextPageToken})
73 require.NoError(t, err)
74 require.Len(t, p2.Issues, 1)
75 assert.Equal(t, c.ID, p2.Issues[0].ID)
76 assert.Empty(t, p2.NextPageToken)
77 _, err = ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageToken: "x"})
78 assert.ErrorIs(t, err, domain.ErrInvalid)
79
80 cnt, err := ps.Count(ctx)
81 require.NoError(t, err)
82 assert.Equal(t, 3, cnt)
83
84 require.NoError(t, ps.Delete(ctx, n, a.ID))
85 assert.ErrorIs(t, ps.Delete(ctx, n, a.ID), domain.ErrNotFound)
86 cnt, err = ps.Count(ctx)
87 require.NoError(t, err)
88 assert.Equal(t, 2, cnt)
89}
90
91func TestPendingIssueCountIsCapped(t *testing.T) {
92 ctx := context.Background()
93 s, _, n := repoWithMRs(t)
94 ps := s.PendingIssues()
95 for i := range domain.MaxPendingIssues + 5 {
96 require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))))
97 }
98 cnt, err := ps.Count(ctx)
99 require.NoError(t, err)
100 assert.Equal(t, domain.MaxPendingIssues+1, cnt)
101}
102
103func TestPendingIssueApproveSharesNumbers(t *testing.T) {
104 ctx := context.Background()
105 s, mrs, n := repoWithMRs(t)
106 ps := s.PendingIssues()
107
108 mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
109 require.NoError(t, err)
110 assert.Equal(t, int64(1), mr.Number)
111
112 named := newPending(t, n, "named", "Alex", created)
113 anon := newPending(t, n, "anon", "", created.Add(time.Second))
114 require.NoError(t, ps.Put(ctx, named))
115 require.NoError(t, ps.Put(ctx, anon))
116
117 issue, err := ps.Approve(ctx, n, named.ID, created.Add(time.Hour))
118 require.NoError(t, err)
119 assert.Equal(t, int64(2), issue.Number)
120 assert.Equal(t, domain.IssueStateOpen, issue.State)
121 assert.Equal(t, "Alex", issue.Author)
122 assert.Equal(t, "named", issue.Title.String())
123 assert.True(t, issue.External, "an approved submission is external")
124
125 _, err = ps.Get(ctx, n, named.ID)
126 assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone")
127 _, err = ps.Approve(ctx, n, named.ID, created.Add(time.Hour))
128 assert.ErrorIs(t, err, domain.ErrNotFound, "approved once")
129
130 got, err := s.Issues().Get(ctx, n, 2)
131 require.NoError(t, err)
132 assert.Equal(t, issue.Title, got.Title)
133 assert.True(t, got.External, "the flag is stored")
134 open := openOf(t, s.Issues(), n)
135 require.Len(t, open, 1)
136 assert.Equal(t, int64(2), open[0].Number)
137
138 second, err := ps.Approve(ctx, n, anon.ID, created.Add(time.Hour))
139 require.NoError(t, err)
140 assert.Equal(t, int64(3), second.Number)
141 assert.Equal(t, "anonymous", second.Author)
142
143 next, err := s.Issues().Create(ctx, newIssue(t, n, "owner"))
144 require.NoError(t, err)
145 assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter")
146 assert.False(t, next.External, "an issue of the owner is not external")
147}
148
149func TestPendingIssueApproveConcurrent(t *testing.T) {
150 ctx := context.Background()
151 s, mrs, n := repoWithMRs(t)
152 ps := s.PendingIssues()
153 const k = 8
154 ids := make([]domain.PendingIssueID, k)
155 for i := range k {
156 p := newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))
157 ids[i] = p.ID
158 require.NoError(t, ps.Put(ctx, p))
159 }
160 var mu sync.Mutex
161 seen := map[int64]bool{}
162 var wg sync.WaitGroup
163 for _, id := range ids {
164 wg.Add(1)
165 go func() {
166 defer wg.Done()
167 is, err := ps.Approve(ctx, n, id, created.Add(time.Hour))
168 if assert.NoError(t, err) {
169 mu.Lock()
170 seen[is.Number] = true
171 mu.Unlock()
172 }
173 }()
174 }
175 wg.Add(1)
176 go func() {
177 defer wg.Done()
178 mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
179 if assert.NoError(t, err) {
180 mu.Lock()
181 seen[mr.Number] = true
182 mu.Unlock()
183 }
184 }()
185 wg.Wait()
186 assert.Len(t, seen, k+1, "no number is given twice")
187}
188
189func TestPendingIssueApproveOnDeletedRepoFails(t *testing.T) {
190 ctx := context.Background()
191 s, _, n := repoWithMRs(t)
192 ps := s.PendingIssues()
193 p := newPending(t, n, "t", "", created)
194 require.NoError(t, ps.Put(ctx, p))
195 require.NoError(t, s.Delete(ctx, n))
196
197 _, err := ps.Approve(ctx, n, p.ID, created.Add(time.Hour))
198 assert.ErrorIs(t, err, domain.ErrNotFound)
199 assert.ErrorIs(t, ps.Put(ctx, newPending(t, n, "t", "", created)), domain.ErrNotFound)
200 assert.Zero(t, countKeys(t, s, "is/"))
201 assert.Zero(t, countKeys(t, s, "isopen/"))
202}
203
204func TestRepoDeleteRemovesPendingIssues(t *testing.T) {
205 ctx := context.Background()
206 s, _, n := repoWithMRs(t)
207 other := name(t, "other")
208 require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
209 ps := s.PendingIssues()
210 require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created)))
211 require.NoError(t, ps.Put(ctx, newPending(t, n, "u", "", created)))
212 keep := newPending(t, other, "k", "", created)
213 require.NoError(t, ps.Put(ctx, keep))
214
215 require.NoError(t, s.Delete(ctx, n))
216 assert.Zero(t, countKeys(t, s, "ispend/"+n.String()+"/"))
217 _, err := ps.Get(ctx, other, keep.ID)
218 assert.NoError(t, err, "another repository keeps its pending issues")
219 cnt, err := ps.Count(ctx)
220 require.NoError(t, err)
221 assert.Equal(t, 1, cnt)
222}
223
224func TestIssueBansExpire(t *testing.T) {
225 ctx := context.Background()
226 s := openStore(t, t.TempDir())
227 bans := s.IssueBans()
228 h := bytes.Repeat([]byte{1}, domain.IPHashLen)
229 other := bytes.Repeat([]byte{2}, domain.IPHashLen)
230 now := created
231
232 banned, err := bans.Banned(ctx, h, now)
233 require.NoError(t, err)
234 assert.False(t, banned)
235
236 require.NoError(t, bans.Ban(ctx, h, now.Add(domain.BanDuration)))
237 require.ErrorIs(t, bans.Ban(ctx, []byte("short"), now), domain.ErrInvalid)
238 banned, err = bans.Banned(ctx, h, now.Add(time.Hour))
239 require.NoError(t, err)
240 assert.True(t, banned)
241 banned, err = bans.Banned(ctx, other, now.Add(time.Hour))
242 require.NoError(t, err)
243 assert.False(t, banned, "another hash is not banned")
244 assert.Equal(t, 1, countKeys(t, s, "isban/"))
245
246 banned, err = bans.Banned(ctx, h, now.Add(domain.BanDuration))
247 require.NoError(t, err)
248 assert.False(t, banned, "expired")
249 assert.Zero(t, countKeys(t, s, "isban/"), "an expired ban is deleted")
250}
251
252func TestIssueKeyPersists(t *testing.T) {
253 ctx := context.Background()
254 dir := t.TempDir()
255 s := openStore(t, dir)
256 key, err := s.IssueKeys().Key(ctx)
257 require.NoError(t, err)
258 assert.Len(t, key, 32)
259 again, err := s.IssueKeys().Key(ctx)
260 require.NoError(t, err)
261 assert.Equal(t, key, again)
262 sess, err := s.Sessions().Key(ctx)
263 require.NoError(t, err)
264 assert.NotEqual(t, sess, key, "not the session key")
265 require.NoError(t, s.Close(ctx))
266
267 s = openStore(t, dir)
268 reopened, err := s.IssueKeys().Key(ctx)
269 require.NoError(t, err)
270 assert.Equal(t, key, reopened, "a restart keeps the key")
271}