1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "crypto/sha256"
8 "encoding/base64"
9 "errors"
10 "math/bits"
11 "strconv"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "testing"
16 "time"
17
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/mock"
20 "github.com/stretchr/testify/require"
21
22 "bnema.dev/ermite/internal/domain"
23 "bnema.dev/ermite/internal/ports"
24 "bnema.dev/ermite/internal/ports/mocks"
25 "bnema.dev/ermite/internal/usecase"
26)
27
28const testBits = 8
29
30type subFixture struct {
31 t *testing.T
32 meta *mocks.RepoMetaStore
33 pending *mocks.PendingIssueStore
34 bans *mocks.IssueBanStore
35 limiter *mocks.SubmissionLimiter
36 bus *mocks.EventBus
37 uc *usecase.IssueSubmissions
38 repo domain.Repo
39 addr string
40
41 mu sync.Mutex
42 now time.Time
43}
44
45func (f *subFixture) advance(d time.Duration) {
46 f.mu.Lock()
47 defer f.mu.Unlock()
48 f.now = f.now.Add(d)
49}
50
51func newSubFixture(t *testing.T) *subFixture {
52 t.Helper()
53 f := &subFixture{
54 t: t, meta: mocks.NewRepoMetaStore(t), pending: mocks.NewPendingIssueStore(t),
55 bans: mocks.NewIssueBanStore(t), limiter: mocks.NewSubmissionLimiter(t), bus: mocks.NewEventBus(t),
56 addr: "ip:203.0.113.7", now: time.Date(2026, 10, 19, 9, 0, 0, 0, time.UTC),
57 }
58 f.repo = domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, CreatedAt: f.now, Settings: withIssues(domain.IssuesPublic)}
59 f.meta.EXPECT().Get(mock.Anything, f.repo.Name).Return(f.repo, nil).Maybe()
60 clock := mocks.NewClock(t)
61 clock.EXPECT().Now().RunAndReturn(func() time.Time {
62 f.mu.Lock()
63 defer f.mu.Unlock()
64 return f.now
65 }).Maybe()
66 keyStore := mocks.NewIssueKeyStore(t)
67 keyStore.EXPECT().Key(mock.Anything).Return([]byte(strings.Repeat("k", 32)), nil)
68 keys, err := usecase.NewIssueKeys(context.Background(), keyStore)
69 require.NoError(t, err)
70 f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
71 Meta: f.meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Keys: keys, Bus: f.bus, Clock: clock,
72 BaseDifficulty: testBits, MaxDifficulty: testBits + 3,
73 })
74 return f
75}
76
77// open expects the checks after the proof of work: not banned, quota
78// available, room. It returns the number of reservations released.
79func (f *subFixture) open(pendingCount int) *atomic.Int32 {
80 released := new(atomic.Int32)
81 f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
82 f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, string) (bool, time.Duration, func()) {
83 return true, 0, func() { released.Add(1) }
84 }).Maybe()
85 f.pending.EXPECT().Count(mock.Anything).Return(pendingCount, nil).Maybe()
86 return released
87}
88
89func (f *subFixture) challenge(addr string) ports.IssueChallenge {
90 f.t.Helper()
91 f.limiter.EXPECT().Recent(mock.Anything, addr).Return(0, 0).Once()
92 ch, err := f.uc.Challenge(context.Background(), f.repo.Name, addr)
93 require.NoError(f.t, err)
94 return ch
95}
96
97func solve(t *testing.T, ch ports.IssueChallenge) string {
98 t.Helper()
99 nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce)
100 require.NoError(t, err)
101 for i := 0; ; i++ {
102 s := strconv.Itoa(i)
103 sum := sha256.Sum256(append(append(append([]byte(nil), nonce...), ':'), s...))
104 if zeroBits(sum[:]) >= ch.Difficulty {
105 return s
106 }
107 }
108}
109
110func zeroBits(b []byte) int {
111 n := 0
112 for _, x := range b {
113 if x == 0 {
114 n += 8
115 continue
116 }
117 return n + bits.LeadingZeros8(x)
118 }
119 return n
120}
121
122// valid is a submission that passes every check once the fill time is over.
123func (f *subFixture) valid(ch ports.IssueChallenge) ports.IssueSubmission {
124 return ports.IssueSubmission{
125 Token: ch.Token, Solution: solve(f.t, ch), FormToken: ch.FormToken,
126 Title: "A bug", Body: "It breaks.", Author: "me",
127 }
128}
129
130func TestIssueChallenge(t *testing.T) {
131 t.Parallel()
132 f := newSubFixture(t)
133 ch := f.challenge(f.addr)
134 assert.Equal(t, testBits, ch.Difficulty)
135 assert.Equal(t, f.now, ch.IssuedAt)
136 assert.Equal(t, f.now.Add(usecase.MinFillTime), ch.MinSubmitAt)
137 assert.Equal(t, f.now.Add(usecase.ChallengeTTL), ch.ExpiresAt)
138 assert.Contains(t, ch.Token, ".")
139 nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce)
140 require.NoError(t, err)
141 assert.Len(t, nonce, 16)
142 assert.NotEmpty(t, ch.FormToken)
143
144 f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(4, 40).Once() // +2 +2
145 ch, err = f.uc.Challenge(context.Background(), f.repo.Name, f.addr)
146 require.NoError(t, err)
147 assert.Equal(t, testBits+3, ch.Difficulty) // capped at base+3
148}
149
150func TestIssueSubmissionRepoAccess(t *testing.T) {
151 t.Parallel()
152 for name_, repo := range map[string]domain.Repo{
153 "private": {Visibility: domain.VisibilityPrivate, Settings: withIssues(domain.IssuesPublic)},
154 "owner issues": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOwner)},
155 "issues off": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOff)},
156 } {
157 t.Run(name_, func(t *testing.T) {
158 t.Parallel()
159 f := newSubFixture(t)
160 meta := mocks.NewRepoMetaStore(t)
161 repo.Name = f.repo.Name
162 meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil)
163 f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Bus: f.bus, Clock: mocks.NewClock(t)})
164 _, err := f.uc.Challenge(context.Background(), repo.Name, f.addr)
165 assert.ErrorIs(t, err, domain.ErrNotFound)
166 err = f.uc.Submit(context.Background(), repo.Name, f.addr, ports.IssueSubmission{})
167 assert.ErrorIs(t, err, domain.ErrNotFound)
168 })
169 }
170 t.Run("unknown", func(t *testing.T) {
171 t.Parallel()
172 f := newSubFixture(t)
173 other := name(t, "other")
174 f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{}, domain.ErrNotFound)
175 assert.ErrorIs(t, f.uc.Submit(context.Background(), other, f.addr, ports.IssueSubmission{}), domain.ErrNotFound)
176 _, err := f.uc.Challenge(context.Background(), other, f.addr)
177 assert.ErrorIs(t, err, domain.ErrNotFound)
178 })
179}
180
181func TestIssueSubmitSuccess(t *testing.T) {
182 t.Parallel()
183 f := newSubFixture(t)
184 f.open(0)
185 ch := f.challenge(f.addr)
186 sub := f.valid(ch)
187 f.advance(usecase.MinFillTime)
188 var put domain.PendingIssue
189 f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.PendingIssue) error {
190 put = p
191 return nil
192 }).Once()
193 f.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
194 e, ok := ev.(domain.IssueSubmitted)
195 return ok && e.Repo == f.repo.Name && e.ID == put.ID
196 })).Once()
197 require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
198 assert.Equal(t, "A bug", put.Title.String())
199 assert.Equal(t, "It breaks.", put.Body.String())
200 assert.Equal(t, "me", put.Author)
201 assert.Equal(t, f.repo.Name, put.Repo)
202 assert.Len(t, put.IPHash, domain.IPHashLen)
203 assert.False(t, strings.Contains(string(put.IPHash), f.addr))
204 assert.Equal(t, f.now, put.CreatedAt)
205}
206
207// TestIssueSubmitRefusals: each failing check returns its sentinel; the
208// strict mocks fail the test on any Put, Record or Publish.
209func TestIssueSubmitRefusals(t *testing.T) {
210 t.Parallel()
211 type tc struct {
212 name string
213 want error
214 arrange func(f *subFixture, ch *ports.IssueChallenge, sub *ports.IssueSubmission) (addr string)
215 wait time.Duration
216 setup func(f *subFixture) // replaces f.open
217 }
218 wait := usecase.MinFillTime
219 cases := []tc{
220 {name: "banned", want: domain.ErrSubmissionInvalid, wait: wait, setup: func(f *subFixture) {
221 f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
222 }},
223 {name: "rate limited", want: domain.ErrSubmissionRateLimited, wait: wait, setup: func(f *subFixture) {
224 f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
225 f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(false, 5*time.Minute, func() {})
226 }},
227 {name: "paused at 200", want: domain.ErrSubmissionsPaused, wait: wait, setup: func(f *subFixture) {
228 f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
229 f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {})
230 f.pending.EXPECT().Count(mock.Anything).Return(domain.MaxPendingIssues, nil)
231 }},
232 {name: "honeypot", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
233 s.Honeypot = "http://spam"
234 return ""
235 }},
236 {name: "forged signature", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
237 p, sig, _ := strings.Cut(s.Token, ".")
238 b := []byte(sig)
239 b[0] ^= 1 // still base64url? flip between 'A'/'B'
240 if b[0] > 'z' || b[0] < '0' {
241 b[0] = 'A'
242 }
243 if string(b) == sig {
244 b[0] = 'B'
245 }
246 s.Token = p + "." + string(b)
247 return ""
248 }},
249 {name: "forged payload", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
250 _, sig, _ := strings.Cut(s.Token, ".")
251 s.Token = base64.RawURLEncoding.EncodeToString([]byte(`{"v":1,"d":1}`)) + "." + sig
252 return ""
253 }},
254 {name: "token of another repo", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string {
255 // a valid token for "other", sent to "proj"
256 other := name(f.t, "other")
257 f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other, Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesPublic)}, nil)
258 f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(0, 0).Once()
259 c2, err := f.uc.Challenge(context.Background(), other, f.addr)
260 require.NoError(f.t, err)
261 *s = f.valid(c2)
262 return ""
263 }},
264 {name: "expired", want: domain.ErrSubmissionInvalid, wait: usecase.ChallengeTTL + time.Second},
265 {name: "other ip", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, _ *ports.IssueChallenge, _ *ports.IssueSubmission) string {
266 return "ip:198.51.100.9"
267 }},
268 {name: "too fast", want: domain.ErrSubmissionInvalid, wait: wait - time.Second},
269 {name: "wrong form token", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
270 s.FormToken = "AAAA"
271 return ""
272 }},
273 {name: "insufficient pow", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string {
274 // a counter that does not solve it
275 nonce, _ := base64.RawURLEncoding.DecodeString(ch.Nonce)
276 for i := 0; ; i++ {
277 sum := sha256.Sum256(append(append(nonce, ':'), strconv.Itoa(i)...))
278 if zeroBits(sum[:]) < ch.Difficulty {
279 s.Solution = strconv.Itoa(i)
280 return ""
281 }
282 }
283 }},
284 {name: "non digit solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
285 s.Solution = "12a"
286 return ""
287 }},
288 {name: "empty solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
289 s.Solution = ""
290 return ""
291 }},
292 {name: "huge solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
293 s.Solution = strings.Repeat("1", 21)
294 return ""
295 }},
296 }
297 for _, c := range cases {
298 t.Run(c.name, func(t *testing.T) {
299 t.Parallel()
300 f := newSubFixture(t)
301 if c.setup != nil {
302 c.setup(f)
303 } else {
304 f.open(0)
305 }
306 ch := f.challenge(f.addr)
307 sub := f.valid(ch)
308 addr := f.addr
309 if c.arrange != nil {
310 if a := c.arrange(f, &ch, &sub); a != "" {
311 addr = a
312 }
313 }
314 f.advance(c.wait)
315 err := f.uc.Submit(context.Background(), f.repo.Name, addr, sub)
316 require.ErrorIs(t, err, c.want)
317 assert.ErrorIs(t, err, domain.ErrSubmissionRefused)
318 if c.want == domain.ErrSubmissionRateLimited {
319 var re *domain.SubmissionRetryError
320 require.ErrorAs(t, err, &re)
321 assert.Equal(t, 5*time.Minute, re.After)
322 }
323 })
324 }
325}
326
327func TestIssueSubmitReplayedNonce(t *testing.T) {
328 t.Parallel()
329 f := newSubFixture(t)
330 f.open(0)
331 ch := f.challenge(f.addr)
332 sub := f.valid(ch)
333 f.advance(usecase.MinFillTime)
334 // first: text refused, nonce is spent anyway (nothing reserved)
335 bad := sub
336 bad.Title = ""
337 var te *domain.TextError
338 require.ErrorAs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, bad), &te)
339 assert.Equal(t, 1, f.uc.SpentLen())
340 err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)
341 assert.ErrorIs(t, err, domain.ErrSubmissionInvalid)
342 assert.False(t, errors.Is(err, domain.ErrInvalidText))
343}
344
345func TestIssueSubmitReplayAfterSuccess(t *testing.T) {
346 t.Parallel()
347 f := newSubFixture(t)
348 f.open(0)
349 ch := f.challenge(f.addr)
350 sub := f.valid(ch)
351 f.advance(usecase.MinFillTime)
352 f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Once()
353 f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Once()
354 require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
355 assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionInvalid)
356}
357
358func TestIssueSubmitTextErrors(t *testing.T) {
359 t.Parallel()
360 for name_, mod := range map[string]func(*ports.IssueSubmission){
361 "title": func(s *ports.IssueSubmission) { s.Title = "two\nlines" },
362 "body": func(s *ports.IssueSubmission) { s.Body = strings.Repeat("x", domain.MaxIssueBodyLen+1) },
363 "author": func(s *ports.IssueSubmission) { s.Author = strings.Repeat("x", domain.MaxAuthorLabelLen+1) },
364 } {
365 t.Run(name_, func(t *testing.T) {
366 t.Parallel()
367 f := newSubFixture(t)
368 f.open(0)
369 ch := f.challenge(f.addr)
370 sub := f.valid(ch)
371 mod(&sub)
372 f.advance(usecase.MinFillTime)
373 err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)
374 var te *domain.TextError
375 require.ErrorAs(t, err, &te)
376 assert.NotErrorIs(t, err, domain.ErrSubmissionRefused)
377 })
378 }
379}
380
381func TestIssueSubmitSpentSetFull(t *testing.T) {
382 t.Parallel()
383 f := newSubFixture(t)
384 f.open(0)
385 ch := f.challenge(f.addr)
386 sub := f.valid(ch)
387 f.advance(usecase.MinFillTime)
388 f.uc.FillSpent(usecase.MaxSpent, f.now.Add(time.Hour))
389 assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionsPaused)
390 assert.Equal(t, usecase.MaxSpent, f.uc.SpentLen()) // nothing live evicted
391}
392
393func TestIssueSubmitSpentSetExpiry(t *testing.T) {
394 t.Parallel()
395 f := newSubFixture(t)
396 f.open(0)
397 f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Maybe()
398 f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
399 ch := f.challenge(f.addr)
400 sub := f.valid(ch)
401 f.advance(usecase.MinFillTime)
402 require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
403 assert.Equal(t, 1, f.uc.SpentLen())
404 f.advance(usecase.ChallengeTTL + 2*time.Minute)
405 ch2 := f.challenge(f.addr)
406 sub2 := f.valid(ch2)
407 f.advance(usecase.MinFillTime)
408 require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub2))
409 assert.Equal(t, 1, f.uc.SpentLen(), "the expired nonce was swept")
410
411 // a full set of expired nonces is swept, not a reason to pause
412 f.uc.FillSpent(usecase.MaxSpent, f.now.Add(-time.Second))
413 ch3 := f.challenge(f.addr)
414 sub3 := f.valid(ch3)
415 f.advance(usecase.MinFillTime)
416 require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub3))
417 assert.Equal(t, 2, f.uc.SpentLen(), "expired fillers swept, live nonces kept")
418}
419
420func TestIssueSubmitGarbageDoesNotPanic(t *testing.T) {
421 t.Parallel()
422 f := newSubFixture(t)
423 f.open(0)
424 ch := f.challenge(f.addr)
425 p, sig, _ := strings.Cut(ch.Token, ".")
426 huge := strings.Repeat("A", 1<<20)
427 tokens := []string{
428 "", ".", "..", "a", "a.b", "!!!.???", huge, huge + "." + huge, p, p + ".", "." + sig,
429 p + "." + sig + "." + sig, p + "." + sig[:len(sig)-3], p[:len(p)/2] + "." + sig,
430 base64.RawURLEncoding.EncodeToString([]byte("null")) + "." + sig,
431 base64.StdEncoding.EncodeToString([]byte("{}")) + "==." + sig, "\x00\xff.\x00",
432 }
433 f.advance(usecase.MinFillTime)
434 for _, tok := range tokens {
435 for _, s := range []ports.IssueSubmission{
436 {Token: tok},
437 {Token: tok, Solution: huge, FormToken: huge, Title: huge, Body: huge, Author: huge},
438 {Token: tok, Solution: "0", FormToken: "\xff"},
439 } {
440 err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, s)
441 assert.ErrorIs(t, err, domain.ErrSubmissionInvalid)
442 }
443 }
444 // the genuine token with garbage around it
445 for _, s := range []ports.IssueSubmission{
446 {Token: ch.Token, FormToken: huge, Solution: "1"},
447 {Token: ch.Token, FormToken: ch.FormToken, Solution: huge},
448 } {
449 assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, s), domain.ErrSubmissionInvalid)
450 }
451}
452
453func TestIssueSubmissionHonoursContext(t *testing.T) {
454 t.Parallel()
455 f := newSubFixture(t)
456 ctx, cancel := context.WithCancel(context.Background())
457 cancel()
458 assert.ErrorIs(t, f.uc.Submit(ctx, f.repo.Name, f.addr, ports.IssueSubmission{}), context.Canceled)
459 _, err := f.uc.Challenge(ctx, f.repo.Name, f.addr)
460 assert.ErrorIs(t, err, context.Canceled)
461}
462
463func TestDifficulty(t *testing.T) {
464 t.Parallel()
465 for _, c := range []struct{ perAddr, global, want int }{
466 {0, 0, 18}, {1, 0, 18}, {2, 0, 19}, {3, 0, 19}, {4, 0, 20}, {6, 0, 21}, {100, 0, 21},
467 {0, 19, 18}, {0, 20, 19}, {0, 40, 20}, {0, 60, 21}, {0, 1000, 21},
468 {2, 20, 20}, {4, 20, 21}, {-5, -5, 18},
469 } {
470 assert.Equal(t, c.want, usecase.DifficultyFor(c.perAddr, c.global), "%d/%d", c.perAddr, c.global)
471 }
472 assert.Equal(t, 18, usecase.BaseDifficulty)
473 assert.Equal(t, 21, usecase.MaxDifficulty)
474}
475
476// submitters builds n valid, distinct submissions (one challenge each) for the
477// addresses addrOf(i) and the repositories repoOf(i), ready to be sent.
478type prepared struct {
479 repo domain.RepoName
480 addr string
481 sub ports.IssueSubmission
482}
483
484func (f *subFixture) prepare(n int, addrOf func(int) string, repoOf func(int) domain.RepoName) []prepared {
485 out := make([]prepared, n)
486 for i := range n {
487 addr := addrOf(i)
488 repo := repoOf(i)
489 f.limiter.EXPECT().Recent(mock.Anything, addr).Return(0, 0).Once()
490 ch, err := f.uc.Challenge(context.Background(), repo, addr)
491 require.NoError(f.t, err)
492 out[i] = prepared{repo: repo, addr: addr, sub: f.valid(ch)}
493 }
494 f.advance(usecase.MinFillTime)
495 return out
496}
497
498// race sends every prepared submission at once and returns the errors.
499func (f *subFixture) race(ps []prepared) []error {
500 errs := make([]error, len(ps))
501 var wg sync.WaitGroup
502 start := make(chan struct{})
503 for i, p := range ps {
504 wg.Add(1)
505 go func() {
506 defer wg.Done()
507 <-start
508 errs[i] = f.uc.Submit(context.Background(), p.repo, p.addr, p.sub)
509 }()
510 }
511 close(start)
512 wg.Wait()
513 return errs
514}
515
516func TestIssueSubmitConcurrentSameAddrRespectsQuota(t *testing.T) {
517 t.Parallel()
518 f := newSubFixture(t)
519 f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
520 f.pending.EXPECT().Count(mock.Anything).Return(0, nil).Maybe()
521 // a real, atomic limiter semantics: PerHour grants then refusals
522 var granted atomic.Int32
523 var rlMu sync.Mutex
524 f.limiter.EXPECT().Reserve(mock.Anything, f.addr).RunAndReturn(func(context.Context, string) (bool, time.Duration, func()) {
525 rlMu.Lock()
526 defer rlMu.Unlock()
527 if granted.Load() >= 3 {
528 return false, time.Minute, func() {}
529 }
530 granted.Add(1)
531 return true, 0, func() {}
532 }).Maybe()
533 var puts atomic.Int32
534 f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.PendingIssue) error {
535 puts.Add(1)
536 return nil
537 }).Maybe()
538 f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
539 ps := f.prepare(20, func(int) string { return f.addr }, func(int) domain.RepoName { return f.repo.Name })
540 var ok, limited int
541 for _, err := range f.race(ps) {
542 var re *domain.SubmissionRetryError
543 switch {
544 case err == nil:
545 ok++
546 case errors.As(err, &re):
547 limited++
548 default:
549 t.Fatalf("unexpected error: %v", err)
550 }
551 }
552 assert.Equal(t, 3, ok)
553 assert.Equal(t, 17, limited)
554 assert.Equal(t, int32(3), puts.Load())
555}
556
557func TestIssueSubmitConcurrentRespectsPendingCap(t *testing.T) {
558 t.Parallel()
559 f := newSubFixture(t)
560 f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
561 f.limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}).Maybe()
562 other := name(t, "other")
563 f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other, Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesPublic)}, nil).Maybe()
564 var count atomic.Int32
565 count.Store(domain.MaxPendingIssues - 1)
566 f.pending.EXPECT().Count(mock.Anything).RunAndReturn(func(context.Context) (int, error) {
567 return int(count.Load()), nil
568 }).Maybe()
569 var puts atomic.Int32
570 f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.PendingIssue) error {
571 puts.Add(1)
572 count.Add(1)
573 return nil
574 }).Maybe()
575 f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
576 ps := f.prepare(20, func(i int) string { return "ip:198.51.100." + strconv.Itoa(i+1) }, func(i int) domain.RepoName {
577 if i%2 == 0 {
578 return f.repo.Name
579 }
580 return other
581 })
582 var ok, paused int
583 for _, err := range f.race(ps) {
584 switch {
585 case err == nil:
586 ok++
587 case errors.Is(err, domain.ErrSubmissionsPaused):
588 paused++
589 default:
590 t.Fatalf("unexpected error: %v", err)
591 }
592 }
593 assert.Equal(t, 1, ok)
594 assert.Equal(t, 19, paused)
595 assert.Equal(t, int32(1), puts.Load())
596}
597
598func TestIssueSubmitReleasesTheReservationWhenStoreFails(t *testing.T) {
599 t.Parallel()
600 f := newSubFixture(t)
601 released := f.open(0)
602 boom := errors.New("disk full")
603 f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(boom).Once()
604 ch := f.challenge(f.addr)
605 sub := f.valid(ch)
606 f.advance(usecase.MinFillTime)
607 require.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), boom)
608 assert.Equal(t, int32(1), released.Load())
609
610 f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Once()
611 f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Once()
612 ch = f.challenge(f.addr)
613 sub = f.valid(ch)
614 f.advance(usecase.MinFillTime)
615 require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
616 assert.Equal(t, int32(1), released.Load(), "a stored submission keeps its reservation")
617}
618
619func TestIssueSubmitReleasesTheReservationWhenPaused(t *testing.T) {
620 t.Parallel()
621 f := newSubFixture(t)
622 released := f.open(domain.MaxPendingIssues)
623 ch := f.challenge(f.addr)
624 sub := f.valid(ch)
625 f.advance(usecase.MinFillTime)
626 assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionsPaused)
627 assert.Equal(t, int32(1), released.Load())
628}
629
630// TestIssueSubmitNoStateOracleBeforeProofOfWork: a banned client, a limited
631// one, or a full queue all get the same answer to an invalid submission, and
632// no limiter, count or store is touched (the mocks have no expectation).
633func TestIssueSubmitNoStateOracleBeforeProofOfWork(t *testing.T) {
634 t.Parallel()
635 f := newSubFixture(t)
636 ch := f.challenge(f.addr)
637 valid := f.valid(ch)
638 f.advance(usecase.MinFillTime)
639 bad := []ports.IssueSubmission{
640 {},
641 {Token: "garbage", Solution: "x", FormToken: "y", Title: "", Body: ""},
642 {Token: ch.Token, Honeypot: "x"},
643 {Token: ch.Token, FormToken: ch.FormToken, Solution: "nope"},
644 {Token: valid.Token, FormToken: valid.FormToken, Solution: "abc", Title: "two\nlines"}, // text error + bad pow
645 }
646 for _, s := range bad {
647 err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, s)
648 require.ErrorIs(t, err, domain.ErrSubmissionInvalid)
649 assert.NotErrorIs(t, err, domain.ErrInvalidText)
650 }
651 // a fully valid submission from a banned client: still invalid, not
652 // limited, paused or a text error, even with bad text.
653 f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(true, nil).Once()
654 bannedBadText := valid
655 bannedBadText.Title = ""
656 err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, bannedBadText)
657 require.ErrorIs(t, err, domain.ErrSubmissionInvalid)
658 assert.NotErrorIs(t, err, domain.ErrInvalidText)
659 var re *domain.SubmissionRetryError
660 assert.False(t, errors.As(err, &re))
661 assert.NotErrorIs(t, err, domain.ErrSubmissionsPaused)
662}
663
664func TestIssueChallengeRevealsNoState(t *testing.T) {
665 t.Parallel()
666 f := newSubFixture(t) // no Banned, Reserve or Count expectation
667 f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(50, 500).Once()
668 _, err := f.uc.Challenge(context.Background(), f.repo.Name, f.addr)
669 require.NoError(t, err)
670}