detached 59d5a0b

issue_submissions_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close