detached a26798b

feat(usecase): anonymous issue submission with adaptive proof of work

commit
a26798b8b4656cf229f3091f47c7a172e13ab582
author
brice · · 2026-10-19 07:12 UTC
parent
ee8d39c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +1144 -1
M internal/app/forge.go +7 -1
@@ -121,7 +121,9 @@ type forge struct {
121121 IssueModeration *usecase.IssueModeration
122122 // IssueKeys are the subkeys of the public issue form, and the stores and
123123 // the limiter its submissions use (the form itself comes later).
124- IssueKeys *usecase.IssueKeys
124+ IssueKeys *usecase.IssueKeys
125+ // IssueSubmissions serves the anonymous submission of issues.
126+ IssueSubmissions *usecase.IssueSubmissions
125127 SubmissionLimiter *clientlimit.SubmissionLimiter
126128 PendingIssues ports.PendingIssueStore
127129 IssueBans ports.IssueBanStore
@@ -340,6 +342,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
340342 if err != nil {
341343 return nil, err
342344 }
345+ f.IssueSubmissions = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
346+ Meta: store, Pending: f.PendingIssues, Bans: f.IssueBans, Limiter: f.SubmissionLimiter,
347+ Keys: f.IssueKeys, Bus: f.Bus, Clock: clock,
348+ })
343349 f.Inbox = usecase.NewInbox(usecase.InboxConfig{
344350 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
345351 })
@@ -121,7 +121,9 @@ type forge struct {
121 IssueModeration *usecase.IssueModeration121 IssueModeration *usecase.IssueModeration
122 // IssueKeys are the subkeys of the public issue form, and the stores and122 // IssueKeys are the subkeys of the public issue form, and the stores and
123 // the limiter its submissions use (the form itself comes later).123 // the limiter its submissions use (the form itself comes later).
124- IssueKeys *usecase.IssueKeys124+ IssueKeys *usecase.IssueKeys
125+ // IssueSubmissions serves the anonymous submission of issues.
126+ IssueSubmissions *usecase.IssueSubmissions
125 SubmissionLimiter *clientlimit.SubmissionLimiter127 SubmissionLimiter *clientlimit.SubmissionLimiter
126 PendingIssues ports.PendingIssueStore128 PendingIssues ports.PendingIssueStore
127 IssueBans ports.IssueBanStore129 IssueBans ports.IssueBanStore
@@ -340,6 +342,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
340 if err != nil {342 if err != nil {
341 return nil, err343 return nil, err
342 }344 }
345+ f.IssueSubmissions = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
346+ Meta: store, Pending: f.PendingIssues, Bans: f.IssueBans, Limiter: f.SubmissionLimiter,
347+ Keys: f.IssueKeys, Bus: f.Bus, Clock: clock,
348+ })
343 f.Inbox = usecase.NewInbox(usecase.InboxConfig{349 f.Inbox = usecase.NewInbox(usecase.InboxConfig{
344 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),350 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
345 })351 })
A internal/domain/submission.go +42 -0
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "time"
9+)
10+
11+// ErrSubmissionRefused is matched by every refusal of an anonymous issue
12+// submission, whatever the class. Unknown or closed repositories answer
13+// ErrNotFound instead, and a text that breaks the rules a *TextError.
14+var ErrSubmissionRefused = errors.New("submission refused")
15+
16+var (
17+ // ErrSubmissionRateLimited means the client submitted too often; the
18+ // error is a *SubmissionRetryError that says when to retry.
19+ ErrSubmissionRateLimited = fmt.Errorf("%w: rate limited", ErrSubmissionRefused)
20+ // ErrSubmissionsPaused means too many submissions await moderation (or
21+ // the server cannot track more challenges): nobody may submit for now.
22+ ErrSubmissionsPaused = fmt.Errorf("%w: submissions paused", ErrSubmissionRefused)
23+ // ErrSubmissionInvalid covers every bot, token, proof-of-work, timing and
24+ // honeypot failure. It is deliberately one error: it must not tell a bot
25+ // which check it failed.
26+ ErrSubmissionInvalid = fmt.Errorf("%w: invalid submission", ErrSubmissionRefused)
27+)
28+
29+// SubmissionRetryError is the ErrSubmissionRateLimited of a client that may
30+// try again after the given delay.
31+type SubmissionRetryError struct {
32+ After time.Duration
33+}
34+
35+// Error implements error.
36+func (e *SubmissionRetryError) Error() string {
37+ return fmt.Sprintf("%v: retry after %s", ErrSubmissionRateLimited, e.After.Round(time.Second))
38+}
39+
40+// Unwrap makes errors.Is match ErrSubmissionRateLimited and
41+// ErrSubmissionRefused.
42+func (e *SubmissionRetryError) Unwrap() error { return ErrSubmissionRateLimited }
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "time"
9+)
10+
11+// ErrSubmissionRefused is matched by every refusal of an anonymous issue
12+// submission, whatever the class. Unknown or closed repositories answer
13+// ErrNotFound instead, and a text that breaks the rules a *TextError.
14+var ErrSubmissionRefused = errors.New("submission refused")
15+
16+var (
17+ // ErrSubmissionRateLimited means the client submitted too often; the
18+ // error is a *SubmissionRetryError that says when to retry.
19+ ErrSubmissionRateLimited = fmt.Errorf("%w: rate limited", ErrSubmissionRefused)
20+ // ErrSubmissionsPaused means too many submissions await moderation (or
21+ // the server cannot track more challenges): nobody may submit for now.
22+ ErrSubmissionsPaused = fmt.Errorf("%w: submissions paused", ErrSubmissionRefused)
23+ // ErrSubmissionInvalid covers every bot, token, proof-of-work, timing and
24+ // honeypot failure. It is deliberately one error: it must not tell a bot
25+ // which check it failed.
26+ ErrSubmissionInvalid = fmt.Errorf("%w: invalid submission", ErrSubmissionRefused)
27+)
28+
29+// SubmissionRetryError is the ErrSubmissionRateLimited of a client that may
30+// try again after the given delay.
31+type SubmissionRetryError struct {
32+ After time.Duration
33+}
34+
35+// Error implements error.
36+func (e *SubmissionRetryError) Error() string {
37+ return fmt.Sprintf("%v: retry after %s", ErrSubmissionRateLimited, e.After.Round(time.Second))
38+}
39+
40+// Unwrap makes errors.Is match ErrSubmissionRateLimited and
41+// ErrSubmissionRefused.
42+func (e *SubmissionRetryError) Unwrap() error { return ErrSubmissionRateLimited }
A internal/ports/mocks/IssueSubmissionService.go +190 -0
@@ -0,0 +1,190 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueSubmissionService creates a new instance of IssueSubmissionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueSubmissionService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueSubmissionService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueSubmissionService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueSubmissionService is an autogenerated mock type for the IssueSubmissionService type
39+type IssueSubmissionService struct {
40+ mock.Mock
41+}
42+
43+type IssueSubmissionService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueSubmissionService) EXPECT() *IssueSubmissionService_Expecter {
48+ return &IssueSubmissionService_Expecter{mock: &_m.Mock}
49+}
50+
51+// Challenge provides a mock function for the type IssueSubmissionService
52+func (_mock *IssueSubmissionService) Challenge(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error) {
53+ ret := _mock.Called(ctx, repo, addr)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Challenge")
57+ }
58+
59+ var r0 ports.IssueChallenge
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) (ports.IssueChallenge, error)); ok {
62+ return returnFunc(ctx, repo, addr)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) ports.IssueChallenge); ok {
65+ r0 = returnFunc(ctx, repo, addr)
66+ } else {
67+ r0 = ret.Get(0).(ports.IssueChallenge)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string) error); ok {
70+ r1 = returnFunc(ctx, repo, addr)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueSubmissionService_Challenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Challenge'
78+type IssueSubmissionService_Challenge_Call struct {
79+ *mock.Call
80+}
81+
82+// Challenge is a helper method to define mock.On call
83+// - ctx context.Context
84+// - repo domain.RepoName
85+// - addr string
86+func (_e *IssueSubmissionService_Expecter) Challenge(ctx any, repo any, addr any) *IssueSubmissionService_Challenge_Call {
87+ return &IssueSubmissionService_Challenge_Call{Call: _e.mock.On("Challenge", ctx, repo, addr)}
88+}
89+
90+func (_c *IssueSubmissionService_Challenge_Call) Run(run func(ctx context.Context, repo domain.RepoName, addr string)) *IssueSubmissionService_Challenge_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.RepoName
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.RepoName)
99+ }
100+ var arg2 string
101+ if args[2] != nil {
102+ arg2 = args[2].(string)
103+ }
104+ run(
105+ arg0,
106+ arg1,
107+ arg2,
108+ )
109+ })
110+ return _c
111+}
112+
113+func (_c *IssueSubmissionService_Challenge_Call) Return(issueChallenge ports.IssueChallenge, err error) *IssueSubmissionService_Challenge_Call {
114+ _c.Call.Return(issueChallenge, err)
115+ return _c
116+}
117+
118+func (_c *IssueSubmissionService_Challenge_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error)) *IssueSubmissionService_Challenge_Call {
119+ _c.Call.Return(run)
120+ return _c
121+}
122+
123+// Submit provides a mock function for the type IssueSubmissionService
124+func (_mock *IssueSubmissionService) Submit(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error {
125+ ret := _mock.Called(ctx, repo, addr, f)
126+
127+ if len(ret) == 0 {
128+ panic("no return value specified for Submit")
129+ }
130+
131+ var r0 error
132+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, ports.IssueSubmission) error); ok {
133+ r0 = returnFunc(ctx, repo, addr, f)
134+ } else {
135+ r0 = ret.Error(0)
136+ }
137+ return r0
138+}
139+
140+// IssueSubmissionService_Submit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Submit'
141+type IssueSubmissionService_Submit_Call struct {
142+ *mock.Call
143+}
144+
145+// Submit is a helper method to define mock.On call
146+// - ctx context.Context
147+// - repo domain.RepoName
148+// - addr string
149+// - f ports.IssueSubmission
150+func (_e *IssueSubmissionService_Expecter) Submit(ctx any, repo any, addr any, f any) *IssueSubmissionService_Submit_Call {
151+ return &IssueSubmissionService_Submit_Call{Call: _e.mock.On("Submit", ctx, repo, addr, f)}
152+}
153+
154+func (_c *IssueSubmissionService_Submit_Call) Run(run func(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission)) *IssueSubmissionService_Submit_Call {
155+ _c.Call.Run(func(args mock.Arguments) {
156+ var arg0 context.Context
157+ if args[0] != nil {
158+ arg0 = args[0].(context.Context)
159+ }
160+ var arg1 domain.RepoName
161+ if args[1] != nil {
162+ arg1 = args[1].(domain.RepoName)
163+ }
164+ var arg2 string
165+ if args[2] != nil {
166+ arg2 = args[2].(string)
167+ }
168+ var arg3 ports.IssueSubmission
169+ if args[3] != nil {
170+ arg3 = args[3].(ports.IssueSubmission)
171+ }
172+ run(
173+ arg0,
174+ arg1,
175+ arg2,
176+ arg3,
177+ )
178+ })
179+ return _c
180+}
181+
182+func (_c *IssueSubmissionService_Submit_Call) Return(err error) *IssueSubmissionService_Submit_Call {
183+ _c.Call.Return(err)
184+ return _c
185+}
186+
187+func (_c *IssueSubmissionService_Submit_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error) *IssueSubmissionService_Submit_Call {
188+ _c.Call.Return(run)
189+ return _c
190+}
@@ -0,0 +1,190 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueSubmissionService creates a new instance of IssueSubmissionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueSubmissionService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueSubmissionService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueSubmissionService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueSubmissionService is an autogenerated mock type for the IssueSubmissionService type
39+type IssueSubmissionService struct {
40+ mock.Mock
41+}
42+
43+type IssueSubmissionService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueSubmissionService) EXPECT() *IssueSubmissionService_Expecter {
48+ return &IssueSubmissionService_Expecter{mock: &_m.Mock}
49+}
50+
51+// Challenge provides a mock function for the type IssueSubmissionService
52+func (_mock *IssueSubmissionService) Challenge(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error) {
53+ ret := _mock.Called(ctx, repo, addr)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Challenge")
57+ }
58+
59+ var r0 ports.IssueChallenge
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) (ports.IssueChallenge, error)); ok {
62+ return returnFunc(ctx, repo, addr)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) ports.IssueChallenge); ok {
65+ r0 = returnFunc(ctx, repo, addr)
66+ } else {
67+ r0 = ret.Get(0).(ports.IssueChallenge)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string) error); ok {
70+ r1 = returnFunc(ctx, repo, addr)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueSubmissionService_Challenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Challenge'
78+type IssueSubmissionService_Challenge_Call struct {
79+ *mock.Call
80+}
81+
82+// Challenge is a helper method to define mock.On call
83+// - ctx context.Context
84+// - repo domain.RepoName
85+// - addr string
86+func (_e *IssueSubmissionService_Expecter) Challenge(ctx any, repo any, addr any) *IssueSubmissionService_Challenge_Call {
87+ return &IssueSubmissionService_Challenge_Call{Call: _e.mock.On("Challenge", ctx, repo, addr)}
88+}
89+
90+func (_c *IssueSubmissionService_Challenge_Call) Run(run func(ctx context.Context, repo domain.RepoName, addr string)) *IssueSubmissionService_Challenge_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.RepoName
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.RepoName)
99+ }
100+ var arg2 string
101+ if args[2] != nil {
102+ arg2 = args[2].(string)
103+ }
104+ run(
105+ arg0,
106+ arg1,
107+ arg2,
108+ )
109+ })
110+ return _c
111+}
112+
113+func (_c *IssueSubmissionService_Challenge_Call) Return(issueChallenge ports.IssueChallenge, err error) *IssueSubmissionService_Challenge_Call {
114+ _c.Call.Return(issueChallenge, err)
115+ return _c
116+}
117+
118+func (_c *IssueSubmissionService_Challenge_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error)) *IssueSubmissionService_Challenge_Call {
119+ _c.Call.Return(run)
120+ return _c
121+}
122+
123+// Submit provides a mock function for the type IssueSubmissionService
124+func (_mock *IssueSubmissionService) Submit(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error {
125+ ret := _mock.Called(ctx, repo, addr, f)
126+
127+ if len(ret) == 0 {
128+ panic("no return value specified for Submit")
129+ }
130+
131+ var r0 error
132+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, ports.IssueSubmission) error); ok {
133+ r0 = returnFunc(ctx, repo, addr, f)
134+ } else {
135+ r0 = ret.Error(0)
136+ }
137+ return r0
138+}
139+
140+// IssueSubmissionService_Submit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Submit'
141+type IssueSubmissionService_Submit_Call struct {
142+ *mock.Call
143+}
144+
145+// Submit is a helper method to define mock.On call
146+// - ctx context.Context
147+// - repo domain.RepoName
148+// - addr string
149+// - f ports.IssueSubmission
150+func (_e *IssueSubmissionService_Expecter) Submit(ctx any, repo any, addr any, f any) *IssueSubmissionService_Submit_Call {
151+ return &IssueSubmissionService_Submit_Call{Call: _e.mock.On("Submit", ctx, repo, addr, f)}
152+}
153+
154+func (_c *IssueSubmissionService_Submit_Call) Run(run func(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission)) *IssueSubmissionService_Submit_Call {
155+ _c.Call.Run(func(args mock.Arguments) {
156+ var arg0 context.Context
157+ if args[0] != nil {
158+ arg0 = args[0].(context.Context)
159+ }
160+ var arg1 domain.RepoName
161+ if args[1] != nil {
162+ arg1 = args[1].(domain.RepoName)
163+ }
164+ var arg2 string
165+ if args[2] != nil {
166+ arg2 = args[2].(string)
167+ }
168+ var arg3 ports.IssueSubmission
169+ if args[3] != nil {
170+ arg3 = args[3].(ports.IssueSubmission)
171+ }
172+ run(
173+ arg0,
174+ arg1,
175+ arg2,
176+ arg3,
177+ )
178+ })
179+ return _c
180+}
181+
182+func (_c *IssueSubmissionService_Submit_Call) Return(err error) *IssueSubmissionService_Submit_Call {
183+ _c.Call.Return(err)
184+ return _c
185+}
186+
187+func (_c *IssueSubmissionService_Submit_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error) *IssueSubmissionService_Submit_Call {
188+ _c.Call.Return(run)
189+ return _c
190+}
M internal/ports/services.go +40 -0
@@ -148,6 +148,46 @@ type IssueModerationService interface {
148148 Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error
149149 }
150150
151+// IssueChallenge is what the public form needs to submit: a signed token, the
152+// proof-of-work to solve and the timing.
153+type IssueChallenge struct {
154+ // Token is the signed challenge, sent back untouched.
155+ Token string
156+ // Nonce is the base64url of the 16 random bytes the browser hashes:
157+ // SHA-256(nonce bytes || ":" || decimal counter) must have Difficulty
158+ // leading zero bits.
159+ Nonce string
160+ // Difficulty is the number of leading zero bits required.
161+ Difficulty int
162+ // FormToken is a hidden field of the form, sent back untouched.
163+ FormToken string
164+ // IssuedAt, MinSubmitAt and ExpiresAt bound when the form may be sent.
165+ IssuedAt, MinSubmitAt, ExpiresAt time.Time
166+}
167+
168+// IssueSubmission is what the browser sends back.
169+type IssueSubmission struct {
170+ Token string
171+ // Solution is the decimal counter that solves the proof of work.
172+ Solution string
173+ FormToken string
174+ // Honeypot must be empty: a field only a bot fills.
175+ Honeypot string
176+ Title, Body, Author string
177+}
178+
179+// IssueSubmissionService is the anonymous issue submission. Both calls answer
180+// domain.ErrNotFound for a repository that is unknown, not public or without
181+// public issues. Submit refuses with an error matching
182+// domain.ErrSubmissionRefused (ErrSubmissionRateLimited, ErrSubmissionsPaused,
183+// ErrSubmissionInvalid) or with a *domain.TextError the user can fix.
184+// addr is the client key (clientlimit.Key of the address).
185+// *usecase.IssueSubmissions implements it.
186+type IssueSubmissionService interface {
187+ Challenge(ctx context.Context, repo domain.RepoName, addr string) (IssueChallenge, error)
188+ Submit(ctx context.Context, repo domain.RepoName, addr string, f IssueSubmission) error
189+}
190+
151191 // IssueNumberResolver tells whether a number of a repository names an issue or
152192 // a merge request, for the callers that follow a "#n" reference. *usecase.Issues
153193 // implements it.
@@ -148,6 +148,46 @@ type IssueModerationService interface {
148 Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error148 Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error
149 }149 }
150 150
151+// IssueChallenge is what the public form needs to submit: a signed token, the
152+// proof-of-work to solve and the timing.
153+type IssueChallenge struct {
154+ // Token is the signed challenge, sent back untouched.
155+ Token string
156+ // Nonce is the base64url of the 16 random bytes the browser hashes:
157+ // SHA-256(nonce bytes || ":" || decimal counter) must have Difficulty
158+ // leading zero bits.
159+ Nonce string
160+ // Difficulty is the number of leading zero bits required.
161+ Difficulty int
162+ // FormToken is a hidden field of the form, sent back untouched.
163+ FormToken string
164+ // IssuedAt, MinSubmitAt and ExpiresAt bound when the form may be sent.
165+ IssuedAt, MinSubmitAt, ExpiresAt time.Time
166+}
167+
168+// IssueSubmission is what the browser sends back.
169+type IssueSubmission struct {
170+ Token string
171+ // Solution is the decimal counter that solves the proof of work.
172+ Solution string
173+ FormToken string
174+ // Honeypot must be empty: a field only a bot fills.
175+ Honeypot string
176+ Title, Body, Author string
177+}
178+
179+// IssueSubmissionService is the anonymous issue submission. Both calls answer
180+// domain.ErrNotFound for a repository that is unknown, not public or without
181+// public issues. Submit refuses with an error matching
182+// domain.ErrSubmissionRefused (ErrSubmissionRateLimited, ErrSubmissionsPaused,
183+// ErrSubmissionInvalid) or with a *domain.TextError the user can fix.
184+// addr is the client key (clientlimit.Key of the address).
185+// *usecase.IssueSubmissions implements it.
186+type IssueSubmissionService interface {
187+ Challenge(ctx context.Context, repo domain.RepoName, addr string) (IssueChallenge, error)
188+ Submit(ctx context.Context, repo domain.RepoName, addr string, f IssueSubmission) error
189+}
190+
151 // IssueNumberResolver tells whether a number of a repository names an issue or191 // IssueNumberResolver tells whether a number of a repository names an issue or
152 // a merge request, for the callers that follow a "#n" reference. *usecase.Issues192 // a merge request, for the callers that follow a "#n" reference. *usecase.Issues
153 // implements it.193 // implements it.
M internal/usecase/export_test.go +34 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "crypto/rsa"
88 "slices"
9+ "strconv"
910 "time"
1011
1112 "bnema.dev/ermite-actions/expr"
@@ -130,3 +131,36 @@ func (o *OIDC) SignForTest(claims map[string]any) (string, error) {
130131 }
131132 return o.sign(out)
132133 }
134+
135+// DifficultyFor exposes the adaptive difficulty with the production base and
136+// cap to the tests.
137+func DifficultyFor(perAddr, global int) int {
138+ return difficultyFor(baseDifficulty, maxDifficulty, perAddr, global)
139+}
140+
141+// Production parameters of the submission, for the tests.
142+const (
143+ BaseDifficulty = baseDifficulty
144+ MaxDifficulty = maxDifficulty
145+ MinFillTime = minFillTime
146+ ChallengeTTL = challengeTTL
147+)
148+
149+// SpentLen is the number of nonces the submission remembers as spent.
150+func (s *IssueSubmissions) SpentLen() int {
151+ s.mu.Lock()
152+ defer s.mu.Unlock()
153+ return len(s.spent)
154+}
155+
156+// FillSpent fills the spent set with n unexpired nonces.
157+func (s *IssueSubmissions) FillSpent(n int, exp time.Time) {
158+ s.mu.Lock()
159+ defer s.mu.Unlock()
160+ for i := range n {
161+ s.spent["fill"+strconv.Itoa(i)] = exp
162+ }
163+}
164+
165+// MaxSpent is the bound of the spent set.
166+const MaxSpent = maxSpent
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "crypto/rsa"7 "crypto/rsa"
8 "slices"8 "slices"
9+ "strconv"
9 "time"10 "time"
10 11
11 "bnema.dev/ermite-actions/expr"12 "bnema.dev/ermite-actions/expr"
@@ -130,3 +131,36 @@ func (o *OIDC) SignForTest(claims map[string]any) (string, error) {
130 }131 }
131 return o.sign(out)132 return o.sign(out)
132 }133 }
134+
135+// DifficultyFor exposes the adaptive difficulty with the production base and
136+// cap to the tests.
137+func DifficultyFor(perAddr, global int) int {
138+ return difficultyFor(baseDifficulty, maxDifficulty, perAddr, global)
139+}
140+
141+// Production parameters of the submission, for the tests.
142+const (
143+ BaseDifficulty = baseDifficulty
144+ MaxDifficulty = maxDifficulty
145+ MinFillTime = minFillTime
146+ ChallengeTTL = challengeTTL
147+)
148+
149+// SpentLen is the number of nonces the submission remembers as spent.
150+func (s *IssueSubmissions) SpentLen() int {
151+ s.mu.Lock()
152+ defer s.mu.Unlock()
153+ return len(s.spent)
154+}
155+
156+// FillSpent fills the spent set with n unexpired nonces.
157+func (s *IssueSubmissions) FillSpent(n int, exp time.Time) {
158+ s.mu.Lock()
159+ defer s.mu.Unlock()
160+ for i := range n {
161+ s.spent["fill"+strconv.Itoa(i)] = exp
162+ }
163+}
164+
165+// MaxSpent is the bound of the spent set.
166+const MaxSpent = maxSpent
A internal/usecase/issue_submissions.go +360 -0
@@ -0,0 +1,360 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/hmac"
8+ "crypto/rand"
9+ "crypto/sha256"
10+ "encoding/base64"
11+ "encoding/json"
12+ "errors"
13+ "fmt"
14+ "math/bits"
15+ "strings"
16+ "sync"
17+ "time"
18+
19+ "github.com/bnema/zerowrap"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+)
24+
25+// Parameters of the anonymous submission (ADR 0031).
26+const (
27+ // baseDifficulty is the proof-of-work of a client that has not submitted
28+ // lately, in leading zero bits of SHA-256: 2^18 hashes on average, about
29+ // one second for a SHA-256 in JavaScript on a mid-range phone.
30+ baseDifficulty = 18
31+ // maxDifficulty caps the adaptive proof-of-work: about 8 to 10 seconds.
32+ maxDifficulty = 21
33+ // perAddrStep and globalStep: one more bit per that many submissions of the
34+ // last hour from the client, and from everyone.
35+ perAddrStep = 2
36+ globalStep = 20
37+
38+ // challengeTTL is how long a challenge may be solved and sent.
39+ challengeTTL = 10 * time.Minute
40+ // minFillTime is the least time between the challenge and the submission:
41+ // a human cannot write an issue faster.
42+ minFillTime = 3 * time.Second
43+ // maxSpent bounds the nonces remembered as spent. Past it submissions are
44+ // paused rather than a live nonce forgotten.
45+ maxSpent = 100_000
46+
47+ tokenVersion = 1
48+ nonceLen = 16
49+ maxChallengeLen = 1024
50+ maxSolutionLen = 20
51+ spentSweep = time.Minute
52+)
53+
54+// difficultyFor is the proof-of-work for a client with perAddr submissions in
55+// the last hour among global ones: base, plus a bit per perAddrStep and per
56+// globalStep, at most limit. It decays as the sliding window empties.
57+func difficultyFor(base, limit, perAddr, global int) int {
58+ return min(base+max(perAddr, 0)/perAddrStep+max(global, 0)/globalStep, limit)
59+}
60+
61+// leadingZeroBits counts the zero bits at the start of sum.
62+func leadingZeroBits(sum []byte) int {
63+ n := 0
64+ for _, b := range sum {
65+ if b == 0 {
66+ n += 8
67+ continue
68+ }
69+ return n + bits.LeadingZeros8(b)
70+ }
71+ return n
72+}
73+
74+// powHash is SHA-256(nonce || ":" || solution).
75+func powHash(nonce []byte, solution string) []byte {
76+ h := sha256.New()
77+ h.Write(nonce)
78+ h.Write([]byte(":"))
79+ h.Write([]byte(solution))
80+ return h.Sum(nil)
81+}
82+
83+// challengeClaims is the signed payload of a challenge token.
84+type challengeClaims struct {
85+ V int `json:"v"`
86+ Repo string `json:"repo"`
87+ Nonce []byte `json:"nonce"`
88+ Difficulty int `json:"d"`
89+ IssuedAt int64 `json:"iat"`
90+ ExpiresAt int64 `json:"exp"`
91+ IP []byte `json:"ip"`
92+}
93+
94+// IssueSubmissions is the anonymous submission of issues: a signed challenge
95+// with an adaptive proof of work, and every server-side check before the issue
96+// awaits moderation.
97+type IssueSubmissions struct {
98+ meta ports.RepoMetaStore
99+ pending ports.PendingIssueStore
100+ bans ports.IssueBanStore
101+ limiter ports.SubmissionLimiter
102+ keys *IssueKeys
103+ bus ports.EventBus
104+ clock ports.Clock
105+ base int
106+ limit int
107+
108+ mu sync.Mutex
109+ spent map[string]time.Time // nonce -> expiry of its challenge
110+ nextSweep time.Time
111+}
112+
113+var _ ports.IssueSubmissionService = (*IssueSubmissions)(nil)
114+
115+// IssueSubmissionsConfig holds what the submission needs. Every field but the
116+// difficulties is required.
117+type IssueSubmissionsConfig struct {
118+ Meta ports.RepoMetaStore
119+ Pending ports.PendingIssueStore
120+ Bans ports.IssueBanStore
121+ Limiter ports.SubmissionLimiter
122+ Keys *IssueKeys
123+ Bus ports.EventBus
124+ Clock ports.Clock
125+ // BaseDifficulty and MaxDifficulty replace the production constants; for
126+ // tests. Zero keeps the constants.
127+ BaseDifficulty, MaxDifficulty int
128+}
129+
130+// NewIssueSubmissions builds the submission use case.
131+func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions {
132+ s := &IssueSubmissions{
133+ meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, limiter: cfg.Limiter,
134+ keys: cfg.Keys, bus: cfg.Bus, clock: cfg.Clock,
135+ base: baseDifficulty, limit: maxDifficulty,
136+ spent: make(map[string]time.Time),
137+ }
138+ if cfg.BaseDifficulty > 0 {
139+ s.base = cfg.BaseDifficulty
140+ }
141+ if cfg.MaxDifficulty > 0 {
142+ s.limit = cfg.MaxDifficulty
143+ }
144+ return s
145+}
146+
147+// openRepo returns the repository if anyone may submit to it, else
148+// domain.ErrNotFound.
149+func (s *IssueSubmissions) openRepo(ctx context.Context, repo domain.RepoName) error {
150+ r, err := lookupRepo(ctx, s.meta, repo)
151+ if err != nil {
152+ if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrInvalidName) {
153+ return domain.ErrNotFound
154+ }
155+ return err
156+ }
157+ if !canRead(domain.Anonymous(), r) || r.Settings.Issues.OrDefault() != domain.IssuesPublic {
158+ return domain.ErrNotFound
159+ }
160+ return nil
161+}
162+
163+func (s *IssueSubmissions) sign(key, msg []byte) []byte {
164+ m := hmac.New(sha256.New, key)
165+ m.Write(msg)
166+ return m.Sum(nil)
167+}
168+
169+// Challenge issues a challenge for a client of the repository.
170+func (s *IssueSubmissions) Challenge(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error) {
171+ if err := ctx.Err(); err != nil {
172+ return ports.IssueChallenge{}, err
173+ }
174+ if err := s.openRepo(ctx, repo); err != nil {
175+ return ports.IssueChallenge{}, err
176+ }
177+ nonce := make([]byte, nonceLen)
178+ if _, err := rand.Read(nonce); err != nil {
179+ return ports.IssueChallenge{}, fmt.Errorf("random nonce: %w", err)
180+ }
181+ perAddr, global := s.limiter.Recent(ctx, addr)
182+ d := difficultyFor(s.base, s.limit, perAddr, global)
183+ now := s.clock.Now().UTC()
184+ claims := challengeClaims{
185+ V: tokenVersion, Repo: repo.String(), Nonce: nonce, Difficulty: d,
186+ IssuedAt: now.Unix(), ExpiresAt: now.Add(challengeTTL).Unix(), IP: s.keys.IPHash(addr),
187+ }
188+ payload, err := json.Marshal(claims)
189+ if err != nil {
190+ return ports.IssueChallenge{}, fmt.Errorf("encode challenge: %w", err)
191+ }
192+ return ports.IssueChallenge{
193+ Token: enc64(payload) + "." + enc64(s.sign(s.keys.PoWKey(), payload)),
194+ Nonce: enc64(nonce),
195+ Difficulty: d,
196+ FormToken: enc64(s.sign(s.keys.FormTokenKey(), nonce)),
197+ IssuedAt: time.Unix(claims.IssuedAt, 0).UTC(),
198+ MinSubmitAt: time.Unix(claims.IssuedAt, 0).UTC().Add(minFillTime),
199+ ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(),
200+ }, nil
201+}
202+
203+// parseToken verifies the signature and returns the claims, or false.
204+func (s *IssueSubmissions) parseToken(tok string) (challengeClaims, bool) {
205+ var c challengeClaims
206+ if len(tok) > maxChallengeLen {
207+ return c, false
208+ }
209+ p, sig, ok := strings.Cut(tok, ".")
210+ if !ok {
211+ return c, false
212+ }
213+ payload, err := base64.RawURLEncoding.DecodeString(p)
214+ if err != nil {
215+ return c, false
216+ }
217+ got, err := base64.RawURLEncoding.DecodeString(sig)
218+ if err != nil || !hmac.Equal(got, s.sign(s.keys.PoWKey(), payload)) {
219+ return c, false
220+ }
221+ if json.Unmarshal(payload, &c) != nil {
222+ return c, false
223+ }
224+ return c, true
225+}
226+
227+func validSolution(sol string) bool {
228+ if len(sol) < 1 || len(sol) > maxSolutionLen {
229+ return false
230+ }
231+ for i := 0; i < len(sol); i++ {
232+ if sol[i] < '0' || sol[i] > '9' {
233+ return false
234+ }
235+ }
236+ return true
237+}
238+
239+// spend marks the nonce spent. It returns domain.ErrSubmissionInvalid for a
240+// nonce already spent and domain.ErrSubmissionsPaused when the set is full.
241+func (s *IssueSubmissions) spend(nonce []byte, exp, now time.Time) error {
242+ s.mu.Lock()
243+ defer s.mu.Unlock()
244+ if len(s.spent) >= maxSpent || !now.Before(s.nextSweep) {
245+ for k, e := range s.spent {
246+ if now.After(e) { // an expired challenge is refused anyway
247+ delete(s.spent, k)
248+ }
249+ }
250+ s.nextSweep = now.Add(spentSweep)
251+ }
252+ key := string(nonce)
253+ if _, dup := s.spent[key]; dup {
254+ return domain.ErrSubmissionInvalid
255+ }
256+ if len(s.spent) >= maxSpent {
257+ return domain.ErrSubmissionsPaused
258+ }
259+ s.spent[key] = exp
260+ return nil
261+}
262+
263+// Submit checks the submission and stores it as a pending issue. The checks
264+// run in a fixed order and stop at the first failure; every bot-related
265+// failure answers domain.ErrSubmissionInvalid, and the precise reason is only
266+// logged (without address, hash or text).
267+func (s *IssueSubmissions) Submit(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error {
268+ if err := ctx.Err(); err != nil {
269+ return err
270+ }
271+ log := zerowrap.FromCtx(ctx)
272+ refuse := func(reason string, err error) error {
273+ log.Info().Str("repo", repo.String()).Str("reason", reason).Msg("issue submission refused")
274+ return err
275+ }
276+ if err := s.openRepo(ctx, repo); err != nil {
277+ return err
278+ }
279+ ipHash := s.keys.IPHash(addr)
280+ now := s.clock.Now().UTC()
281+ banned, err := s.bans.Banned(ctx, ipHash, now)
282+ if err != nil {
283+ return fmt.Errorf("check ban: %w", err)
284+ }
285+ if banned {
286+ return refuse("banned", domain.ErrSubmissionInvalid)
287+ }
288+ if ok, after := s.limiter.Allow(ctx, addr); !ok {
289+ return refuse("rate_limited", &domain.SubmissionRetryError{After: after})
290+ }
291+ n, err := s.pending.Count(ctx)
292+ if err != nil {
293+ return fmt.Errorf("count pending issues: %w", err)
294+ }
295+ if n >= domain.MaxPendingIssues {
296+ return refuse("pending_full", domain.ErrSubmissionsPaused)
297+ }
298+ if f.Honeypot != "" {
299+ return refuse("honeypot", domain.ErrSubmissionInvalid)
300+ }
301+ c, ok := s.parseToken(f.Token)
302+ if !ok {
303+ return refuse("bad_token", domain.ErrSubmissionInvalid)
304+ }
305+ exp := time.Unix(c.ExpiresAt, 0)
306+ switch {
307+ case c.V != tokenVersion || len(c.Nonce) != nonceLen:
308+ return refuse("bad_token", domain.ErrSubmissionInvalid)
309+ case c.Repo != repo.String():
310+ return refuse("token_other_repo", domain.ErrSubmissionInvalid)
311+ case now.After(exp):
312+ return refuse("token_expired", domain.ErrSubmissionInvalid)
313+ case !hmac.Equal(c.IP, ipHash):
314+ return refuse("token_other_client", domain.ErrSubmissionInvalid)
315+ }
316+ if !hmac.Equal([]byte(f.FormToken), []byte(enc64(s.sign(s.keys.FormTokenKey(), c.Nonce)))) {
317+ return refuse("bad_form_token", domain.ErrSubmissionInvalid)
318+ }
319+ if now.Before(time.Unix(c.IssuedAt, 0).Add(minFillTime)) {
320+ return refuse("too_fast", domain.ErrSubmissionInvalid)
321+ }
322+ if !validSolution(f.Solution) || c.Difficulty < 1 || c.Difficulty > 8*sha256.Size ||
323+ leadingZeroBits(powHash(c.Nonce, f.Solution)) < c.Difficulty {
324+ return refuse("bad_pow", domain.ErrSubmissionInvalid)
325+ }
326+ if err := s.spend(c.Nonce, exp, now); err != nil {
327+ if errors.Is(err, domain.ErrSubmissionsPaused) {
328+ return refuse("spent_full", err)
329+ }
330+ return refuse("replayed", err)
331+ }
332+ title, err := domain.ParseIssueTitle(f.Title)
333+ if err != nil {
334+ return refuse("text_title", fmt.Errorf("title: %w", err))
335+ }
336+ body, err := domain.ParseIssueBody(f.Body)
337+ if err != nil {
338+ return refuse("text_body", fmt.Errorf("body: %w", err))
339+ }
340+ author, err := domain.ParseAuthorLabel(f.Author)
341+ if err != nil {
342+ return refuse("text_author", fmt.Errorf("author: %w", err))
343+ }
344+ id, err := domain.NewPendingIssueID()
345+ if err != nil {
346+ return fmt.Errorf("new pending issue id: %w", err)
347+ }
348+ s.limiter.Record(ctx, addr)
349+ err = s.pending.Put(ctx, domain.PendingIssue{
350+ ID: id, Repo: repo, Title: title, Body: body, Author: author, CreatedAt: now, IPHash: ipHash,
351+ })
352+ if err != nil {
353+ return fmt.Errorf("store pending issue of %q: %w", repo, err)
354+ }
355+ log.Info().Str("repo", repo.String()).Msg("issue submitted for moderation")
356+ s.bus.Publish(ctx, domain.IssueSubmitted{Repo: repo, ID: id, At: now})
357+ return nil
358+}
359+
360+func enc64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
@@ -0,0 +1,360 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/hmac"
8+ "crypto/rand"
9+ "crypto/sha256"
10+ "encoding/base64"
11+ "encoding/json"
12+ "errors"
13+ "fmt"
14+ "math/bits"
15+ "strings"
16+ "sync"
17+ "time"
18+
19+ "github.com/bnema/zerowrap"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+)
24+
25+// Parameters of the anonymous submission (ADR 0031).
26+const (
27+ // baseDifficulty is the proof-of-work of a client that has not submitted
28+ // lately, in leading zero bits of SHA-256: 2^18 hashes on average, about
29+ // one second for a SHA-256 in JavaScript on a mid-range phone.
30+ baseDifficulty = 18
31+ // maxDifficulty caps the adaptive proof-of-work: about 8 to 10 seconds.
32+ maxDifficulty = 21
33+ // perAddrStep and globalStep: one more bit per that many submissions of the
34+ // last hour from the client, and from everyone.
35+ perAddrStep = 2
36+ globalStep = 20
37+
38+ // challengeTTL is how long a challenge may be solved and sent.
39+ challengeTTL = 10 * time.Minute
40+ // minFillTime is the least time between the challenge and the submission:
41+ // a human cannot write an issue faster.
42+ minFillTime = 3 * time.Second
43+ // maxSpent bounds the nonces remembered as spent. Past it submissions are
44+ // paused rather than a live nonce forgotten.
45+ maxSpent = 100_000
46+
47+ tokenVersion = 1
48+ nonceLen = 16
49+ maxChallengeLen = 1024
50+ maxSolutionLen = 20
51+ spentSweep = time.Minute
52+)
53+
54+// difficultyFor is the proof-of-work for a client with perAddr submissions in
55+// the last hour among global ones: base, plus a bit per perAddrStep and per
56+// globalStep, at most limit. It decays as the sliding window empties.
57+func difficultyFor(base, limit, perAddr, global int) int {
58+ return min(base+max(perAddr, 0)/perAddrStep+max(global, 0)/globalStep, limit)
59+}
60+
61+// leadingZeroBits counts the zero bits at the start of sum.
62+func leadingZeroBits(sum []byte) int {
63+ n := 0
64+ for _, b := range sum {
65+ if b == 0 {
66+ n += 8
67+ continue
68+ }
69+ return n + bits.LeadingZeros8(b)
70+ }
71+ return n
72+}
73+
74+// powHash is SHA-256(nonce || ":" || solution).
75+func powHash(nonce []byte, solution string) []byte {
76+ h := sha256.New()
77+ h.Write(nonce)
78+ h.Write([]byte(":"))
79+ h.Write([]byte(solution))
80+ return h.Sum(nil)
81+}
82+
83+// challengeClaims is the signed payload of a challenge token.
84+type challengeClaims struct {
85+ V int `json:"v"`
86+ Repo string `json:"repo"`
87+ Nonce []byte `json:"nonce"`
88+ Difficulty int `json:"d"`
89+ IssuedAt int64 `json:"iat"`
90+ ExpiresAt int64 `json:"exp"`
91+ IP []byte `json:"ip"`
92+}
93+
94+// IssueSubmissions is the anonymous submission of issues: a signed challenge
95+// with an adaptive proof of work, and every server-side check before the issue
96+// awaits moderation.
97+type IssueSubmissions struct {
98+ meta ports.RepoMetaStore
99+ pending ports.PendingIssueStore
100+ bans ports.IssueBanStore
101+ limiter ports.SubmissionLimiter
102+ keys *IssueKeys
103+ bus ports.EventBus
104+ clock ports.Clock
105+ base int
106+ limit int
107+
108+ mu sync.Mutex
109+ spent map[string]time.Time // nonce -> expiry of its challenge
110+ nextSweep time.Time
111+}
112+
113+var _ ports.IssueSubmissionService = (*IssueSubmissions)(nil)
114+
115+// IssueSubmissionsConfig holds what the submission needs. Every field but the
116+// difficulties is required.
117+type IssueSubmissionsConfig struct {
118+ Meta ports.RepoMetaStore
119+ Pending ports.PendingIssueStore
120+ Bans ports.IssueBanStore
121+ Limiter ports.SubmissionLimiter
122+ Keys *IssueKeys
123+ Bus ports.EventBus
124+ Clock ports.Clock
125+ // BaseDifficulty and MaxDifficulty replace the production constants; for
126+ // tests. Zero keeps the constants.
127+ BaseDifficulty, MaxDifficulty int
128+}
129+
130+// NewIssueSubmissions builds the submission use case.
131+func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions {
132+ s := &IssueSubmissions{
133+ meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, limiter: cfg.Limiter,
134+ keys: cfg.Keys, bus: cfg.Bus, clock: cfg.Clock,
135+ base: baseDifficulty, limit: maxDifficulty,
136+ spent: make(map[string]time.Time),
137+ }
138+ if cfg.BaseDifficulty > 0 {
139+ s.base = cfg.BaseDifficulty
140+ }
141+ if cfg.MaxDifficulty > 0 {
142+ s.limit = cfg.MaxDifficulty
143+ }
144+ return s
145+}
146+
147+// openRepo returns the repository if anyone may submit to it, else
148+// domain.ErrNotFound.
149+func (s *IssueSubmissions) openRepo(ctx context.Context, repo domain.RepoName) error {
150+ r, err := lookupRepo(ctx, s.meta, repo)
151+ if err != nil {
152+ if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrInvalidName) {
153+ return domain.ErrNotFound
154+ }
155+ return err
156+ }
157+ if !canRead(domain.Anonymous(), r) || r.Settings.Issues.OrDefault() != domain.IssuesPublic {
158+ return domain.ErrNotFound
159+ }
160+ return nil
161+}
162+
163+func (s *IssueSubmissions) sign(key, msg []byte) []byte {
164+ m := hmac.New(sha256.New, key)
165+ m.Write(msg)
166+ return m.Sum(nil)
167+}
168+
169+// Challenge issues a challenge for a client of the repository.
170+func (s *IssueSubmissions) Challenge(ctx context.Context, repo domain.RepoName, addr string) (ports.IssueChallenge, error) {
171+ if err := ctx.Err(); err != nil {
172+ return ports.IssueChallenge{}, err
173+ }
174+ if err := s.openRepo(ctx, repo); err != nil {
175+ return ports.IssueChallenge{}, err
176+ }
177+ nonce := make([]byte, nonceLen)
178+ if _, err := rand.Read(nonce); err != nil {
179+ return ports.IssueChallenge{}, fmt.Errorf("random nonce: %w", err)
180+ }
181+ perAddr, global := s.limiter.Recent(ctx, addr)
182+ d := difficultyFor(s.base, s.limit, perAddr, global)
183+ now := s.clock.Now().UTC()
184+ claims := challengeClaims{
185+ V: tokenVersion, Repo: repo.String(), Nonce: nonce, Difficulty: d,
186+ IssuedAt: now.Unix(), ExpiresAt: now.Add(challengeTTL).Unix(), IP: s.keys.IPHash(addr),
187+ }
188+ payload, err := json.Marshal(claims)
189+ if err != nil {
190+ return ports.IssueChallenge{}, fmt.Errorf("encode challenge: %w", err)
191+ }
192+ return ports.IssueChallenge{
193+ Token: enc64(payload) + "." + enc64(s.sign(s.keys.PoWKey(), payload)),
194+ Nonce: enc64(nonce),
195+ Difficulty: d,
196+ FormToken: enc64(s.sign(s.keys.FormTokenKey(), nonce)),
197+ IssuedAt: time.Unix(claims.IssuedAt, 0).UTC(),
198+ MinSubmitAt: time.Unix(claims.IssuedAt, 0).UTC().Add(minFillTime),
199+ ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(),
200+ }, nil
201+}
202+
203+// parseToken verifies the signature and returns the claims, or false.
204+func (s *IssueSubmissions) parseToken(tok string) (challengeClaims, bool) {
205+ var c challengeClaims
206+ if len(tok) > maxChallengeLen {
207+ return c, false
208+ }
209+ p, sig, ok := strings.Cut(tok, ".")
210+ if !ok {
211+ return c, false
212+ }
213+ payload, err := base64.RawURLEncoding.DecodeString(p)
214+ if err != nil {
215+ return c, false
216+ }
217+ got, err := base64.RawURLEncoding.DecodeString(sig)
218+ if err != nil || !hmac.Equal(got, s.sign(s.keys.PoWKey(), payload)) {
219+ return c, false
220+ }
221+ if json.Unmarshal(payload, &c) != nil {
222+ return c, false
223+ }
224+ return c, true
225+}
226+
227+func validSolution(sol string) bool {
228+ if len(sol) < 1 || len(sol) > maxSolutionLen {
229+ return false
230+ }
231+ for i := 0; i < len(sol); i++ {
232+ if sol[i] < '0' || sol[i] > '9' {
233+ return false
234+ }
235+ }
236+ return true
237+}
238+
239+// spend marks the nonce spent. It returns domain.ErrSubmissionInvalid for a
240+// nonce already spent and domain.ErrSubmissionsPaused when the set is full.
241+func (s *IssueSubmissions) spend(nonce []byte, exp, now time.Time) error {
242+ s.mu.Lock()
243+ defer s.mu.Unlock()
244+ if len(s.spent) >= maxSpent || !now.Before(s.nextSweep) {
245+ for k, e := range s.spent {
246+ if now.After(e) { // an expired challenge is refused anyway
247+ delete(s.spent, k)
248+ }
249+ }
250+ s.nextSweep = now.Add(spentSweep)
251+ }
252+ key := string(nonce)
253+ if _, dup := s.spent[key]; dup {
254+ return domain.ErrSubmissionInvalid
255+ }
256+ if len(s.spent) >= maxSpent {
257+ return domain.ErrSubmissionsPaused
258+ }
259+ s.spent[key] = exp
260+ return nil
261+}
262+
263+// Submit checks the submission and stores it as a pending issue. The checks
264+// run in a fixed order and stop at the first failure; every bot-related
265+// failure answers domain.ErrSubmissionInvalid, and the precise reason is only
266+// logged (without address, hash or text).
267+func (s *IssueSubmissions) Submit(ctx context.Context, repo domain.RepoName, addr string, f ports.IssueSubmission) error {
268+ if err := ctx.Err(); err != nil {
269+ return err
270+ }
271+ log := zerowrap.FromCtx(ctx)
272+ refuse := func(reason string, err error) error {
273+ log.Info().Str("repo", repo.String()).Str("reason", reason).Msg("issue submission refused")
274+ return err
275+ }
276+ if err := s.openRepo(ctx, repo); err != nil {
277+ return err
278+ }
279+ ipHash := s.keys.IPHash(addr)
280+ now := s.clock.Now().UTC()
281+ banned, err := s.bans.Banned(ctx, ipHash, now)
282+ if err != nil {
283+ return fmt.Errorf("check ban: %w", err)
284+ }
285+ if banned {
286+ return refuse("banned", domain.ErrSubmissionInvalid)
287+ }
288+ if ok, after := s.limiter.Allow(ctx, addr); !ok {
289+ return refuse("rate_limited", &domain.SubmissionRetryError{After: after})
290+ }
291+ n, err := s.pending.Count(ctx)
292+ if err != nil {
293+ return fmt.Errorf("count pending issues: %w", err)
294+ }
295+ if n >= domain.MaxPendingIssues {
296+ return refuse("pending_full", domain.ErrSubmissionsPaused)
297+ }
298+ if f.Honeypot != "" {
299+ return refuse("honeypot", domain.ErrSubmissionInvalid)
300+ }
301+ c, ok := s.parseToken(f.Token)
302+ if !ok {
303+ return refuse("bad_token", domain.ErrSubmissionInvalid)
304+ }
305+ exp := time.Unix(c.ExpiresAt, 0)
306+ switch {
307+ case c.V != tokenVersion || len(c.Nonce) != nonceLen:
308+ return refuse("bad_token", domain.ErrSubmissionInvalid)
309+ case c.Repo != repo.String():
310+ return refuse("token_other_repo", domain.ErrSubmissionInvalid)
311+ case now.After(exp):
312+ return refuse("token_expired", domain.ErrSubmissionInvalid)
313+ case !hmac.Equal(c.IP, ipHash):
314+ return refuse("token_other_client", domain.ErrSubmissionInvalid)
315+ }
316+ if !hmac.Equal([]byte(f.FormToken), []byte(enc64(s.sign(s.keys.FormTokenKey(), c.Nonce)))) {
317+ return refuse("bad_form_token", domain.ErrSubmissionInvalid)
318+ }
319+ if now.Before(time.Unix(c.IssuedAt, 0).Add(minFillTime)) {
320+ return refuse("too_fast", domain.ErrSubmissionInvalid)
321+ }
322+ if !validSolution(f.Solution) || c.Difficulty < 1 || c.Difficulty > 8*sha256.Size ||
323+ leadingZeroBits(powHash(c.Nonce, f.Solution)) < c.Difficulty {
324+ return refuse("bad_pow", domain.ErrSubmissionInvalid)
325+ }
326+ if err := s.spend(c.Nonce, exp, now); err != nil {
327+ if errors.Is(err, domain.ErrSubmissionsPaused) {
328+ return refuse("spent_full", err)
329+ }
330+ return refuse("replayed", err)
331+ }
332+ title, err := domain.ParseIssueTitle(f.Title)
333+ if err != nil {
334+ return refuse("text_title", fmt.Errorf("title: %w", err))
335+ }
336+ body, err := domain.ParseIssueBody(f.Body)
337+ if err != nil {
338+ return refuse("text_body", fmt.Errorf("body: %w", err))
339+ }
340+ author, err := domain.ParseAuthorLabel(f.Author)
341+ if err != nil {
342+ return refuse("text_author", fmt.Errorf("author: %w", err))
343+ }
344+ id, err := domain.NewPendingIssueID()
345+ if err != nil {
346+ return fmt.Errorf("new pending issue id: %w", err)
347+ }
348+ s.limiter.Record(ctx, addr)
349+ err = s.pending.Put(ctx, domain.PendingIssue{
350+ ID: id, Repo: repo, Title: title, Body: body, Author: author, CreatedAt: now, IPHash: ipHash,
351+ })
352+ if err != nil {
353+ return fmt.Errorf("store pending issue of %q: %w", repo, err)
354+ }
355+ log.Info().Str("repo", repo.String()).Msg("issue submitted for moderation")
356+ s.bus.Publish(ctx, domain.IssueSubmitted{Repo: repo, ID: id, At: now})
357+ return nil
358+}
359+
360+func enc64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
A internal/usecase/issue_submissions_test.go +471 -0
@@ -0,0 +1,471 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "encoding/base64"
9+ "errors"
10+ "math/bits"
11+ "strconv"
12+ "strings"
13+ "sync"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/ports/mocks"
24+ "bnema.dev/ermite/internal/usecase"
25+)
26+
27+const testBits = 8
28+
29+type subFixture struct {
30+ t *testing.T
31+ meta *mocks.RepoMetaStore
32+ pending *mocks.PendingIssueStore
33+ bans *mocks.IssueBanStore
34+ limiter *mocks.SubmissionLimiter
35+ bus *mocks.EventBus
36+ uc *usecase.IssueSubmissions
37+ repo domain.Repo
38+ addr string
39+
40+ mu sync.Mutex
41+ now time.Time
42+}
43+
44+func (f *subFixture) advance(d time.Duration) {
45+ f.mu.Lock()
46+ defer f.mu.Unlock()
47+ f.now = f.now.Add(d)
48+}
49+
50+func newSubFixture(t *testing.T) *subFixture {
51+ t.Helper()
52+ f := &subFixture{
53+ t: t, meta: mocks.NewRepoMetaStore(t), pending: mocks.NewPendingIssueStore(t),
54+ bans: mocks.NewIssueBanStore(t), limiter: mocks.NewSubmissionLimiter(t), bus: mocks.NewEventBus(t),
55+ addr: "ip:203.0.113.7", now: time.Date(2026, 10, 19, 9, 0, 0, 0, time.UTC),
56+ }
57+ f.repo = domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, CreatedAt: f.now, Settings: withIssues(domain.IssuesPublic)}
58+ f.meta.EXPECT().Get(mock.Anything, f.repo.Name).Return(f.repo, nil).Maybe()
59+ clock := mocks.NewClock(t)
60+ clock.EXPECT().Now().RunAndReturn(func() time.Time {
61+ f.mu.Lock()
62+ defer f.mu.Unlock()
63+ return f.now
64+ }).Maybe()
65+ keyStore := mocks.NewIssueKeyStore(t)
66+ keyStore.EXPECT().Key(mock.Anything).Return([]byte(strings.Repeat("k", 32)), nil)
67+ keys, err := usecase.NewIssueKeys(context.Background(), keyStore)
68+ require.NoError(t, err)
69+ f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
70+ Meta: f.meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Keys: keys, Bus: f.bus, Clock: clock,
71+ BaseDifficulty: testBits, MaxDifficulty: testBits + 3,
72+ })
73+ return f
74+}
75+
76+// open expects the checks before the token: not banned, allowed, room.
77+func (f *subFixture) open(pendingCount int) {
78+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
79+ f.limiter.EXPECT().Allow(mock.Anything, mock.Anything).Return(true, time.Duration(0)).Maybe()
80+ f.pending.EXPECT().Count(mock.Anything).Return(pendingCount, nil).Maybe()
81+}
82+
83+func (f *subFixture) challenge(addr string) ports.IssueChallenge {
84+ f.t.Helper()
85+ f.limiter.EXPECT().Recent(mock.Anything, addr).Return(0, 0).Once()
86+ ch, err := f.uc.Challenge(context.Background(), f.repo.Name, addr)
87+ require.NoError(f.t, err)
88+ return ch
89+}
90+
91+func solve(t *testing.T, ch ports.IssueChallenge) string {
92+ t.Helper()
93+ nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce)
94+ require.NoError(t, err)
95+ for i := 0; ; i++ {
96+ s := strconv.Itoa(i)
97+ sum := sha256.Sum256(append(append(append([]byte(nil), nonce...), ':'), s...))
98+ if zeroBits(sum[:]) >= ch.Difficulty {
99+ return s
100+ }
101+ }
102+}
103+
104+func zeroBits(b []byte) int {
105+ n := 0
106+ for _, x := range b {
107+ if x == 0 {
108+ n += 8
109+ continue
110+ }
111+ return n + bits.LeadingZeros8(x)
112+ }
113+ return n
114+}
115+
116+// valid is a submission that passes every check once the fill time is over.
117+func (f *subFixture) valid(ch ports.IssueChallenge) ports.IssueSubmission {
118+ return ports.IssueSubmission{
119+ Token: ch.Token, Solution: solve(f.t, ch), FormToken: ch.FormToken,
120+ Title: "A bug", Body: "It breaks.", Author: "me",
121+ }
122+}
123+
124+func TestIssueChallenge(t *testing.T) {
125+ t.Parallel()
126+ f := newSubFixture(t)
127+ ch := f.challenge(f.addr)
128+ assert.Equal(t, testBits, ch.Difficulty)
129+ assert.Equal(t, f.now, ch.IssuedAt)
130+ assert.Equal(t, f.now.Add(usecase.MinFillTime), ch.MinSubmitAt)
131+ assert.Equal(t, f.now.Add(usecase.ChallengeTTL), ch.ExpiresAt)
132+ assert.Contains(t, ch.Token, ".")
133+ nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce)
134+ require.NoError(t, err)
135+ assert.Len(t, nonce, 16)
136+ assert.NotEmpty(t, ch.FormToken)
137+
138+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(4, 40).Once() // +2 +2
139+ ch, err = f.uc.Challenge(context.Background(), f.repo.Name, f.addr)
140+ require.NoError(t, err)
141+ assert.Equal(t, testBits+3, ch.Difficulty) // capped at base+3
142+}
143+
144+func TestIssueSubmissionRepoAccess(t *testing.T) {
145+ t.Parallel()
146+ for name_, repo := range map[string]domain.Repo{
147+ "private": {Visibility: domain.VisibilityPrivate, Settings: withIssues(domain.IssuesPublic)},
148+ "owner issues": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOwner)},
149+ "issues off": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOff)},
150+ } {
151+ t.Run(name_, func(t *testing.T) {
152+ t.Parallel()
153+ f := newSubFixture(t)
154+ meta := mocks.NewRepoMetaStore(t)
155+ repo.Name = f.repo.Name
156+ meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil)
157+ f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Bus: f.bus, Clock: mocks.NewClock(t)})
158+ _, err := f.uc.Challenge(context.Background(), repo.Name, f.addr)
159+ assert.ErrorIs(t, err, domain.ErrNotFound)
160+ err = f.uc.Submit(context.Background(), repo.Name, f.addr, ports.IssueSubmission{})
161+ assert.ErrorIs(t, err, domain.ErrNotFound)
162+ })
163+ }
164+ t.Run("unknown", func(t *testing.T) {
165+ t.Parallel()
166+ f := newSubFixture(t)
167+ other := name(t, "other")
168+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{}, domain.ErrNotFound)
169+ assert.ErrorIs(t, f.uc.Submit(context.Background(), other, f.addr, ports.IssueSubmission{}), domain.ErrNotFound)
170+ _, err := f.uc.Challenge(context.Background(), other, f.addr)
171+ assert.ErrorIs(t, err, domain.ErrNotFound)
172+ })
173+}
174+
175+func TestIssueSubmitSuccess(t *testing.T) {
176+ t.Parallel()
177+ f := newSubFixture(t)
178+ f.open(0)
179+ ch := f.challenge(f.addr)
180+ sub := f.valid(ch)
181+ f.advance(usecase.MinFillTime)
182+ f.limiter.EXPECT().Record(mock.Anything, f.addr).Once()
183+ var put domain.PendingIssue
184+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.PendingIssue) error {
185+ put = p
186+ return nil
187+ }).Once()
188+ f.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
189+ e, ok := ev.(domain.IssueSubmitted)
190+ return ok && e.Repo == f.repo.Name && e.ID == put.ID
191+ })).Once()
192+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
193+ assert.Equal(t, "A bug", put.Title.String())
194+ assert.Equal(t, "It breaks.", put.Body.String())
195+ assert.Equal(t, "me", put.Author)
196+ assert.Equal(t, f.repo.Name, put.Repo)
197+ assert.Len(t, put.IPHash, domain.IPHashLen)
198+ assert.False(t, strings.Contains(string(put.IPHash), f.addr))
199+ assert.Equal(t, f.now, put.CreatedAt)
200+}
201+
202+// TestIssueSubmitRefusals: each failing check returns its sentinel; the
203+// strict mocks fail the test on any Put, Record or Publish.
204+func TestIssueSubmitRefusals(t *testing.T) {
205+ t.Parallel()
206+ type tc struct {
207+ name string
208+ want error
209+ arrange func(f *subFixture, ch *ports.IssueChallenge, sub *ports.IssueSubmission) (addr string)
210+ wait time.Duration
211+ setup func(f *subFixture) // replaces f.open
212+ }
213+ wait := usecase.MinFillTime
214+ cases := []tc{
215+ {name: "banned", want: domain.ErrSubmissionInvalid, wait: wait, setup: func(f *subFixture) {
216+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
217+ }},
218+ {name: "rate limited", want: domain.ErrSubmissionRateLimited, wait: wait, setup: func(f *subFixture) {
219+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
220+ f.limiter.EXPECT().Allow(mock.Anything, mock.Anything).Return(false, 5*time.Minute)
221+ }},
222+ {name: "paused at 200", want: domain.ErrSubmissionsPaused, wait: wait, setup: func(f *subFixture) {
223+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
224+ f.limiter.EXPECT().Allow(mock.Anything, mock.Anything).Return(true, time.Duration(0))
225+ f.pending.EXPECT().Count(mock.Anything).Return(domain.MaxPendingIssues, nil)
226+ }},
227+ {name: "honeypot", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
228+ s.Honeypot = "http://spam"
229+ return ""
230+ }},
231+ {name: "forged signature", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
232+ p, sig, _ := strings.Cut(s.Token, ".")
233+ b := []byte(sig)
234+ b[0] ^= 1 // still base64url? flip between 'A'/'B'
235+ if b[0] > 'z' || b[0] < '0' {
236+ b[0] = 'A'
237+ }
238+ if string(b) == sig {
239+ b[0] = 'B'
240+ }
241+ s.Token = p + "." + string(b)
242+ return ""
243+ }},
244+ {name: "forged payload", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
245+ _, sig, _ := strings.Cut(s.Token, ".")
246+ s.Token = base64.RawURLEncoding.EncodeToString([]byte(`{"v":1,"d":1}`)) + "." + sig
247+ return ""
248+ }},
249+ {name: "token of another repo", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string {
250+ // a valid token for "other", sent to "proj"
251+ other := name(f.t, "other")
252+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other, Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesPublic)}, nil)
253+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(0, 0).Once()
254+ c2, err := f.uc.Challenge(context.Background(), other, f.addr)
255+ require.NoError(f.t, err)
256+ *s = f.valid(c2)
257+ return ""
258+ }},
259+ {name: "expired", want: domain.ErrSubmissionInvalid, wait: usecase.ChallengeTTL + time.Second},
260+ {name: "other ip", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, _ *ports.IssueChallenge, _ *ports.IssueSubmission) string {
261+ return "ip:198.51.100.9"
262+ }},
263+ {name: "too fast", want: domain.ErrSubmissionInvalid, wait: wait - time.Second},
264+ {name: "wrong form token", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
265+ s.FormToken = "AAAA"
266+ return ""
267+ }},
268+ {name: "insufficient pow", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string {
269+ // a counter that does not solve it
270+ nonce, _ := base64.RawURLEncoding.DecodeString(ch.Nonce)
271+ for i := 0; ; i++ {
272+ sum := sha256.Sum256(append(append(nonce, ':'), strconv.Itoa(i)...))
273+ if zeroBits(sum[:]) < ch.Difficulty {
274+ s.Solution = strconv.Itoa(i)
275+ return ""
276+ }
277+ }
278+ }},
279+ {name: "non digit solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
280+ s.Solution = "12a"
281+ return ""
282+ }},
283+ {name: "empty solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
284+ s.Solution = ""
285+ return ""
286+ }},
287+ {name: "huge solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
288+ s.Solution = strings.Repeat("1", 21)
289+ return ""
290+ }},
291+ }
292+ for _, c := range cases {
293+ t.Run(c.name, func(t *testing.T) {
294+ t.Parallel()
295+ f := newSubFixture(t)
296+ if c.setup != nil {
297+ c.setup(f)
298+ } else {
299+ f.open(0)
300+ }
301+ ch := f.challenge(f.addr)
302+ sub := f.valid(ch)
303+ addr := f.addr
304+ if c.arrange != nil {
305+ if a := c.arrange(f, &ch, &sub); a != "" {
306+ addr = a
307+ }
308+ }
309+ f.advance(c.wait)
310+ err := f.uc.Submit(context.Background(), f.repo.Name, addr, sub)
311+ require.ErrorIs(t, err, c.want)
312+ assert.ErrorIs(t, err, domain.ErrSubmissionRefused)
313+ if c.want == domain.ErrSubmissionRateLimited {
314+ var re *domain.SubmissionRetryError
315+ require.ErrorAs(t, err, &re)
316+ assert.Equal(t, 5*time.Minute, re.After)
317+ }
318+ })
319+ }
320+}
321+
322+func TestIssueSubmitReplayedNonce(t *testing.T) {
323+ t.Parallel()
324+ f := newSubFixture(t)
325+ f.open(0)
326+ ch := f.challenge(f.addr)
327+ sub := f.valid(ch)
328+ f.advance(usecase.MinFillTime)
329+ // first: text refused, nonce is spent anyway
330+ bad := sub
331+ bad.Title = ""
332+ var te *domain.TextError
333+ require.ErrorAs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, bad), &te)
334+ assert.Equal(t, 1, f.uc.SpentLen())
335+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)
336+ assert.ErrorIs(t, err, domain.ErrSubmissionInvalid)
337+ assert.False(t, errors.Is(err, domain.ErrInvalidText))
338+}
339+
340+func TestIssueSubmitReplayAfterSuccess(t *testing.T) {
341+ t.Parallel()
342+ f := newSubFixture(t)
343+ f.open(0)
344+ ch := f.challenge(f.addr)
345+ sub := f.valid(ch)
346+ f.advance(usecase.MinFillTime)
347+ f.limiter.EXPECT().Record(mock.Anything, f.addr).Once()
348+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Once()
349+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Once()
350+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
351+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionInvalid)
352+}
353+
354+func TestIssueSubmitTextErrors(t *testing.T) {
355+ t.Parallel()
356+ for name_, mod := range map[string]func(*ports.IssueSubmission){
357+ "title": func(s *ports.IssueSubmission) { s.Title = "two\nlines" },
358+ "body": func(s *ports.IssueSubmission) { s.Body = strings.Repeat("x", domain.MaxIssueBodyLen+1) },
359+ "author": func(s *ports.IssueSubmission) { s.Author = strings.Repeat("x", domain.MaxAuthorLabelLen+1) },
360+ } {
361+ t.Run(name_, func(t *testing.T) {
362+ t.Parallel()
363+ f := newSubFixture(t)
364+ f.open(0)
365+ ch := f.challenge(f.addr)
366+ sub := f.valid(ch)
367+ mod(&sub)
368+ f.advance(usecase.MinFillTime)
369+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)
370+ var te *domain.TextError
371+ require.ErrorAs(t, err, &te)
372+ assert.NotErrorIs(t, err, domain.ErrSubmissionRefused)
373+ })
374+ }
375+}
376+
377+func TestIssueSubmitSpentSetFull(t *testing.T) {
378+ t.Parallel()
379+ f := newSubFixture(t)
380+ f.open(0)
381+ ch := f.challenge(f.addr)
382+ sub := f.valid(ch)
383+ f.advance(usecase.MinFillTime)
384+ f.uc.FillSpent(usecase.MaxSpent, f.now.Add(time.Hour))
385+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionsPaused)
386+ assert.Equal(t, usecase.MaxSpent, f.uc.SpentLen()) // nothing live evicted
387+}
388+
389+func TestIssueSubmitSpentSetExpiry(t *testing.T) {
390+ t.Parallel()
391+ f := newSubFixture(t)
392+ f.open(0)
393+ f.limiter.EXPECT().Record(mock.Anything, mock.Anything).Maybe()
394+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Maybe()
395+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
396+ ch := f.challenge(f.addr)
397+ sub := f.valid(ch)
398+ f.advance(usecase.MinFillTime)
399+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
400+ assert.Equal(t, 1, f.uc.SpentLen())
401+ f.advance(usecase.ChallengeTTL + 2*time.Minute)
402+ ch2 := f.challenge(f.addr)
403+ sub2 := f.valid(ch2)
404+ f.advance(usecase.MinFillTime)
405+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub2))
406+ assert.Equal(t, 1, f.uc.SpentLen(), "the expired nonce was swept")
407+
408+ // a full set of expired nonces is swept, not a reason to pause
409+ f.uc.FillSpent(usecase.MaxSpent, f.now.Add(-time.Second))
410+ ch3 := f.challenge(f.addr)
411+ sub3 := f.valid(ch3)
412+ f.advance(usecase.MinFillTime)
413+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub3))
414+ assert.Equal(t, 2, f.uc.SpentLen(), "expired fillers swept, live nonces kept")
415+}
416+
417+func TestIssueSubmitGarbageDoesNotPanic(t *testing.T) {
418+ t.Parallel()
419+ f := newSubFixture(t)
420+ f.open(0)
421+ ch := f.challenge(f.addr)
422+ p, sig, _ := strings.Cut(ch.Token, ".")
423+ huge := strings.Repeat("A", 1<<20)
424+ tokens := []string{
425+ "", ".", "..", "a", "a.b", "!!!.???", huge, huge + "." + huge, p, p + ".", "." + sig,
426+ p + "." + sig + "." + sig, p + "." + sig[:len(sig)-3], p[:len(p)/2] + "." + sig,
427+ base64.RawURLEncoding.EncodeToString([]byte("null")) + "." + sig,
428+ base64.StdEncoding.EncodeToString([]byte("{}")) + "==." + sig, "\x00\xff.\x00",
429+ }
430+ f.advance(usecase.MinFillTime)
431+ for _, tok := range tokens {
432+ for _, s := range []ports.IssueSubmission{
433+ {Token: tok},
434+ {Token: tok, Solution: huge, FormToken: huge, Title: huge, Body: huge, Author: huge},
435+ {Token: tok, Solution: "0", FormToken: "\xff"},
436+ } {
437+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, s)
438+ assert.ErrorIs(t, err, domain.ErrSubmissionInvalid)
439+ }
440+ }
441+ // the genuine token with garbage around it
442+ for _, s := range []ports.IssueSubmission{
443+ {Token: ch.Token, FormToken: huge, Solution: "1"},
444+ {Token: ch.Token, FormToken: ch.FormToken, Solution: huge},
445+ } {
446+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, s), domain.ErrSubmissionInvalid)
447+ }
448+}
449+
450+func TestIssueSubmissionHonoursContext(t *testing.T) {
451+ t.Parallel()
452+ f := newSubFixture(t)
453+ ctx, cancel := context.WithCancel(context.Background())
454+ cancel()
455+ assert.ErrorIs(t, f.uc.Submit(ctx, f.repo.Name, f.addr, ports.IssueSubmission{}), context.Canceled)
456+ _, err := f.uc.Challenge(ctx, f.repo.Name, f.addr)
457+ assert.ErrorIs(t, err, context.Canceled)
458+}
459+
460+func TestDifficulty(t *testing.T) {
461+ t.Parallel()
462+ for _, c := range []struct{ perAddr, global, want int }{
463+ {0, 0, 18}, {1, 0, 18}, {2, 0, 19}, {3, 0, 19}, {4, 0, 20}, {6, 0, 21}, {100, 0, 21},
464+ {0, 19, 18}, {0, 20, 19}, {0, 40, 20}, {0, 60, 21}, {0, 1000, 21},
465+ {2, 20, 20}, {4, 20, 21}, {-5, -5, 18},
466+ } {
467+ assert.Equal(t, c.want, usecase.DifficultyFor(c.perAddr, c.global), "%d/%d", c.perAddr, c.global)
468+ }
469+ assert.Equal(t, 18, usecase.BaseDifficulty)
470+ assert.Equal(t, 21, usecase.MaxDifficulty)
471+}
@@ -0,0 +1,471 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "encoding/base64"
9+ "errors"
10+ "math/bits"
11+ "strconv"
12+ "strings"
13+ "sync"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/ports/mocks"
24+ "bnema.dev/ermite/internal/usecase"
25+)
26+
27+const testBits = 8
28+
29+type subFixture struct {
30+ t *testing.T
31+ meta *mocks.RepoMetaStore
32+ pending *mocks.PendingIssueStore
33+ bans *mocks.IssueBanStore
34+ limiter *mocks.SubmissionLimiter
35+ bus *mocks.EventBus
36+ uc *usecase.IssueSubmissions
37+ repo domain.Repo
38+ addr string
39+
40+ mu sync.Mutex
41+ now time.Time
42+}
43+
44+func (f *subFixture) advance(d time.Duration) {
45+ f.mu.Lock()
46+ defer f.mu.Unlock()
47+ f.now = f.now.Add(d)
48+}
49+
50+func newSubFixture(t *testing.T) *subFixture {
51+ t.Helper()
52+ f := &subFixture{
53+ t: t, meta: mocks.NewRepoMetaStore(t), pending: mocks.NewPendingIssueStore(t),
54+ bans: mocks.NewIssueBanStore(t), limiter: mocks.NewSubmissionLimiter(t), bus: mocks.NewEventBus(t),
55+ addr: "ip:203.0.113.7", now: time.Date(2026, 10, 19, 9, 0, 0, 0, time.UTC),
56+ }
57+ f.repo = domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, CreatedAt: f.now, Settings: withIssues(domain.IssuesPublic)}
58+ f.meta.EXPECT().Get(mock.Anything, f.repo.Name).Return(f.repo, nil).Maybe()
59+ clock := mocks.NewClock(t)
60+ clock.EXPECT().Now().RunAndReturn(func() time.Time {
61+ f.mu.Lock()
62+ defer f.mu.Unlock()
63+ return f.now
64+ }).Maybe()
65+ keyStore := mocks.NewIssueKeyStore(t)
66+ keyStore.EXPECT().Key(mock.Anything).Return([]byte(strings.Repeat("k", 32)), nil)
67+ keys, err := usecase.NewIssueKeys(context.Background(), keyStore)
68+ require.NoError(t, err)
69+ f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
70+ Meta: f.meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Keys: keys, Bus: f.bus, Clock: clock,
71+ BaseDifficulty: testBits, MaxDifficulty: testBits + 3,
72+ })
73+ return f
74+}
75+
76+// open expects the checks before the token: not banned, allowed, room.
77+func (f *subFixture) open(pendingCount int) {
78+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
79+ f.limiter.EXPECT().Allow(mock.Anything, mock.Anything).Return(true, time.Duration(0)).Maybe()
80+ f.pending.EXPECT().Count(mock.Anything).Return(pendingCount, nil).Maybe()
81+}
82+
83+func (f *subFixture) challenge(addr string) ports.IssueChallenge {
84+ f.t.Helper()
85+ f.limiter.EXPECT().Recent(mock.Anything, addr).Return(0, 0).Once()
86+ ch, err := f.uc.Challenge(context.Background(), f.repo.Name, addr)
87+ require.NoError(f.t, err)
88+ return ch
89+}
90+
91+func solve(t *testing.T, ch ports.IssueChallenge) string {
92+ t.Helper()
93+ nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce)
94+ require.NoError(t, err)
95+ for i := 0; ; i++ {
96+ s := strconv.Itoa(i)
97+ sum := sha256.Sum256(append(append(append([]byte(nil), nonce...), ':'), s...))
98+ if zeroBits(sum[:]) >= ch.Difficulty {
99+ return s
100+ }
101+ }
102+}
103+
104+func zeroBits(b []byte) int {
105+ n := 0
106+ for _, x := range b {
107+ if x == 0 {
108+ n += 8
109+ continue
110+ }
111+ return n + bits.LeadingZeros8(x)
112+ }
113+ return n
114+}
115+
116+// valid is a submission that passes every check once the fill time is over.
117+func (f *subFixture) valid(ch ports.IssueChallenge) ports.IssueSubmission {
118+ return ports.IssueSubmission{
119+ Token: ch.Token, Solution: solve(f.t, ch), FormToken: ch.FormToken,
120+ Title: "A bug", Body: "It breaks.", Author: "me",
121+ }
122+}
123+
124+func TestIssueChallenge(t *testing.T) {
125+ t.Parallel()
126+ f := newSubFixture(t)
127+ ch := f.challenge(f.addr)
128+ assert.Equal(t, testBits, ch.Difficulty)
129+ assert.Equal(t, f.now, ch.IssuedAt)
130+ assert.Equal(t, f.now.Add(usecase.MinFillTime), ch.MinSubmitAt)
131+ assert.Equal(t, f.now.Add(usecase.ChallengeTTL), ch.ExpiresAt)
132+ assert.Contains(t, ch.Token, ".")
133+ nonce, err := base64.RawURLEncoding.DecodeString(ch.Nonce)
134+ require.NoError(t, err)
135+ assert.Len(t, nonce, 16)
136+ assert.NotEmpty(t, ch.FormToken)
137+
138+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(4, 40).Once() // +2 +2
139+ ch, err = f.uc.Challenge(context.Background(), f.repo.Name, f.addr)
140+ require.NoError(t, err)
141+ assert.Equal(t, testBits+3, ch.Difficulty) // capped at base+3
142+}
143+
144+func TestIssueSubmissionRepoAccess(t *testing.T) {
145+ t.Parallel()
146+ for name_, repo := range map[string]domain.Repo{
147+ "private": {Visibility: domain.VisibilityPrivate, Settings: withIssues(domain.IssuesPublic)},
148+ "owner issues": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOwner)},
149+ "issues off": {Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesOff)},
150+ } {
151+ t.Run(name_, func(t *testing.T) {
152+ t.Parallel()
153+ f := newSubFixture(t)
154+ meta := mocks.NewRepoMetaStore(t)
155+ repo.Name = f.repo.Name
156+ meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil)
157+ f.uc = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: meta, Pending: f.pending, Bans: f.bans, Limiter: f.limiter, Bus: f.bus, Clock: mocks.NewClock(t)})
158+ _, err := f.uc.Challenge(context.Background(), repo.Name, f.addr)
159+ assert.ErrorIs(t, err, domain.ErrNotFound)
160+ err = f.uc.Submit(context.Background(), repo.Name, f.addr, ports.IssueSubmission{})
161+ assert.ErrorIs(t, err, domain.ErrNotFound)
162+ })
163+ }
164+ t.Run("unknown", func(t *testing.T) {
165+ t.Parallel()
166+ f := newSubFixture(t)
167+ other := name(t, "other")
168+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{}, domain.ErrNotFound)
169+ assert.ErrorIs(t, f.uc.Submit(context.Background(), other, f.addr, ports.IssueSubmission{}), domain.ErrNotFound)
170+ _, err := f.uc.Challenge(context.Background(), other, f.addr)
171+ assert.ErrorIs(t, err, domain.ErrNotFound)
172+ })
173+}
174+
175+func TestIssueSubmitSuccess(t *testing.T) {
176+ t.Parallel()
177+ f := newSubFixture(t)
178+ f.open(0)
179+ ch := f.challenge(f.addr)
180+ sub := f.valid(ch)
181+ f.advance(usecase.MinFillTime)
182+ f.limiter.EXPECT().Record(mock.Anything, f.addr).Once()
183+ var put domain.PendingIssue
184+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.PendingIssue) error {
185+ put = p
186+ return nil
187+ }).Once()
188+ f.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
189+ e, ok := ev.(domain.IssueSubmitted)
190+ return ok && e.Repo == f.repo.Name && e.ID == put.ID
191+ })).Once()
192+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
193+ assert.Equal(t, "A bug", put.Title.String())
194+ assert.Equal(t, "It breaks.", put.Body.String())
195+ assert.Equal(t, "me", put.Author)
196+ assert.Equal(t, f.repo.Name, put.Repo)
197+ assert.Len(t, put.IPHash, domain.IPHashLen)
198+ assert.False(t, strings.Contains(string(put.IPHash), f.addr))
199+ assert.Equal(t, f.now, put.CreatedAt)
200+}
201+
202+// TestIssueSubmitRefusals: each failing check returns its sentinel; the
203+// strict mocks fail the test on any Put, Record or Publish.
204+func TestIssueSubmitRefusals(t *testing.T) {
205+ t.Parallel()
206+ type tc struct {
207+ name string
208+ want error
209+ arrange func(f *subFixture, ch *ports.IssueChallenge, sub *ports.IssueSubmission) (addr string)
210+ wait time.Duration
211+ setup func(f *subFixture) // replaces f.open
212+ }
213+ wait := usecase.MinFillTime
214+ cases := []tc{
215+ {name: "banned", want: domain.ErrSubmissionInvalid, wait: wait, setup: func(f *subFixture) {
216+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
217+ }},
218+ {name: "rate limited", want: domain.ErrSubmissionRateLimited, wait: wait, setup: func(f *subFixture) {
219+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
220+ f.limiter.EXPECT().Allow(mock.Anything, mock.Anything).Return(false, 5*time.Minute)
221+ }},
222+ {name: "paused at 200", want: domain.ErrSubmissionsPaused, wait: wait, setup: func(f *subFixture) {
223+ f.bans.EXPECT().Banned(mock.Anything, mock.Anything, mock.Anything).Return(false, nil)
224+ f.limiter.EXPECT().Allow(mock.Anything, mock.Anything).Return(true, time.Duration(0))
225+ f.pending.EXPECT().Count(mock.Anything).Return(domain.MaxPendingIssues, nil)
226+ }},
227+ {name: "honeypot", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
228+ s.Honeypot = "http://spam"
229+ return ""
230+ }},
231+ {name: "forged signature", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
232+ p, sig, _ := strings.Cut(s.Token, ".")
233+ b := []byte(sig)
234+ b[0] ^= 1 // still base64url? flip between 'A'/'B'
235+ if b[0] > 'z' || b[0] < '0' {
236+ b[0] = 'A'
237+ }
238+ if string(b) == sig {
239+ b[0] = 'B'
240+ }
241+ s.Token = p + "." + string(b)
242+ return ""
243+ }},
244+ {name: "forged payload", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
245+ _, sig, _ := strings.Cut(s.Token, ".")
246+ s.Token = base64.RawURLEncoding.EncodeToString([]byte(`{"v":1,"d":1}`)) + "." + sig
247+ return ""
248+ }},
249+ {name: "token of another repo", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string {
250+ // a valid token for "other", sent to "proj"
251+ other := name(f.t, "other")
252+ f.meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other, Visibility: domain.VisibilityPublic, Settings: withIssues(domain.IssuesPublic)}, nil)
253+ f.limiter.EXPECT().Recent(mock.Anything, f.addr).Return(0, 0).Once()
254+ c2, err := f.uc.Challenge(context.Background(), other, f.addr)
255+ require.NoError(f.t, err)
256+ *s = f.valid(c2)
257+ return ""
258+ }},
259+ {name: "expired", want: domain.ErrSubmissionInvalid, wait: usecase.ChallengeTTL + time.Second},
260+ {name: "other ip", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(f *subFixture, _ *ports.IssueChallenge, _ *ports.IssueSubmission) string {
261+ return "ip:198.51.100.9"
262+ }},
263+ {name: "too fast", want: domain.ErrSubmissionInvalid, wait: wait - time.Second},
264+ {name: "wrong form token", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
265+ s.FormToken = "AAAA"
266+ return ""
267+ }},
268+ {name: "insufficient pow", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, ch *ports.IssueChallenge, s *ports.IssueSubmission) string {
269+ // a counter that does not solve it
270+ nonce, _ := base64.RawURLEncoding.DecodeString(ch.Nonce)
271+ for i := 0; ; i++ {
272+ sum := sha256.Sum256(append(append(nonce, ':'), strconv.Itoa(i)...))
273+ if zeroBits(sum[:]) < ch.Difficulty {
274+ s.Solution = strconv.Itoa(i)
275+ return ""
276+ }
277+ }
278+ }},
279+ {name: "non digit solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
280+ s.Solution = "12a"
281+ return ""
282+ }},
283+ {name: "empty solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
284+ s.Solution = ""
285+ return ""
286+ }},
287+ {name: "huge solution", want: domain.ErrSubmissionInvalid, wait: wait, arrange: func(_ *subFixture, _ *ports.IssueChallenge, s *ports.IssueSubmission) string {
288+ s.Solution = strings.Repeat("1", 21)
289+ return ""
290+ }},
291+ }
292+ for _, c := range cases {
293+ t.Run(c.name, func(t *testing.T) {
294+ t.Parallel()
295+ f := newSubFixture(t)
296+ if c.setup != nil {
297+ c.setup(f)
298+ } else {
299+ f.open(0)
300+ }
301+ ch := f.challenge(f.addr)
302+ sub := f.valid(ch)
303+ addr := f.addr
304+ if c.arrange != nil {
305+ if a := c.arrange(f, &ch, &sub); a != "" {
306+ addr = a
307+ }
308+ }
309+ f.advance(c.wait)
310+ err := f.uc.Submit(context.Background(), f.repo.Name, addr, sub)
311+ require.ErrorIs(t, err, c.want)
312+ assert.ErrorIs(t, err, domain.ErrSubmissionRefused)
313+ if c.want == domain.ErrSubmissionRateLimited {
314+ var re *domain.SubmissionRetryError
315+ require.ErrorAs(t, err, &re)
316+ assert.Equal(t, 5*time.Minute, re.After)
317+ }
318+ })
319+ }
320+}
321+
322+func TestIssueSubmitReplayedNonce(t *testing.T) {
323+ t.Parallel()
324+ f := newSubFixture(t)
325+ f.open(0)
326+ ch := f.challenge(f.addr)
327+ sub := f.valid(ch)
328+ f.advance(usecase.MinFillTime)
329+ // first: text refused, nonce is spent anyway
330+ bad := sub
331+ bad.Title = ""
332+ var te *domain.TextError
333+ require.ErrorAs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, bad), &te)
334+ assert.Equal(t, 1, f.uc.SpentLen())
335+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)
336+ assert.ErrorIs(t, err, domain.ErrSubmissionInvalid)
337+ assert.False(t, errors.Is(err, domain.ErrInvalidText))
338+}
339+
340+func TestIssueSubmitReplayAfterSuccess(t *testing.T) {
341+ t.Parallel()
342+ f := newSubFixture(t)
343+ f.open(0)
344+ ch := f.challenge(f.addr)
345+ sub := f.valid(ch)
346+ f.advance(usecase.MinFillTime)
347+ f.limiter.EXPECT().Record(mock.Anything, f.addr).Once()
348+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Once()
349+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Once()
350+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
351+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionInvalid)
352+}
353+
354+func TestIssueSubmitTextErrors(t *testing.T) {
355+ t.Parallel()
356+ for name_, mod := range map[string]func(*ports.IssueSubmission){
357+ "title": func(s *ports.IssueSubmission) { s.Title = "two\nlines" },
358+ "body": func(s *ports.IssueSubmission) { s.Body = strings.Repeat("x", domain.MaxIssueBodyLen+1) },
359+ "author": func(s *ports.IssueSubmission) { s.Author = strings.Repeat("x", domain.MaxAuthorLabelLen+1) },
360+ } {
361+ t.Run(name_, func(t *testing.T) {
362+ t.Parallel()
363+ f := newSubFixture(t)
364+ f.open(0)
365+ ch := f.challenge(f.addr)
366+ sub := f.valid(ch)
367+ mod(&sub)
368+ f.advance(usecase.MinFillTime)
369+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub)
370+ var te *domain.TextError
371+ require.ErrorAs(t, err, &te)
372+ assert.NotErrorIs(t, err, domain.ErrSubmissionRefused)
373+ })
374+ }
375+}
376+
377+func TestIssueSubmitSpentSetFull(t *testing.T) {
378+ t.Parallel()
379+ f := newSubFixture(t)
380+ f.open(0)
381+ ch := f.challenge(f.addr)
382+ sub := f.valid(ch)
383+ f.advance(usecase.MinFillTime)
384+ f.uc.FillSpent(usecase.MaxSpent, f.now.Add(time.Hour))
385+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub), domain.ErrSubmissionsPaused)
386+ assert.Equal(t, usecase.MaxSpent, f.uc.SpentLen()) // nothing live evicted
387+}
388+
389+func TestIssueSubmitSpentSetExpiry(t *testing.T) {
390+ t.Parallel()
391+ f := newSubFixture(t)
392+ f.open(0)
393+ f.limiter.EXPECT().Record(mock.Anything, mock.Anything).Maybe()
394+ f.pending.EXPECT().Put(mock.Anything, mock.Anything).Return(nil).Maybe()
395+ f.bus.EXPECT().Publish(mock.Anything, mock.Anything).Maybe()
396+ ch := f.challenge(f.addr)
397+ sub := f.valid(ch)
398+ f.advance(usecase.MinFillTime)
399+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub))
400+ assert.Equal(t, 1, f.uc.SpentLen())
401+ f.advance(usecase.ChallengeTTL + 2*time.Minute)
402+ ch2 := f.challenge(f.addr)
403+ sub2 := f.valid(ch2)
404+ f.advance(usecase.MinFillTime)
405+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub2))
406+ assert.Equal(t, 1, f.uc.SpentLen(), "the expired nonce was swept")
407+
408+ // a full set of expired nonces is swept, not a reason to pause
409+ f.uc.FillSpent(usecase.MaxSpent, f.now.Add(-time.Second))
410+ ch3 := f.challenge(f.addr)
411+ sub3 := f.valid(ch3)
412+ f.advance(usecase.MinFillTime)
413+ require.NoError(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, sub3))
414+ assert.Equal(t, 2, f.uc.SpentLen(), "expired fillers swept, live nonces kept")
415+}
416+
417+func TestIssueSubmitGarbageDoesNotPanic(t *testing.T) {
418+ t.Parallel()
419+ f := newSubFixture(t)
420+ f.open(0)
421+ ch := f.challenge(f.addr)
422+ p, sig, _ := strings.Cut(ch.Token, ".")
423+ huge := strings.Repeat("A", 1<<20)
424+ tokens := []string{
425+ "", ".", "..", "a", "a.b", "!!!.???", huge, huge + "." + huge, p, p + ".", "." + sig,
426+ p + "." + sig + "." + sig, p + "." + sig[:len(sig)-3], p[:len(p)/2] + "." + sig,
427+ base64.RawURLEncoding.EncodeToString([]byte("null")) + "." + sig,
428+ base64.StdEncoding.EncodeToString([]byte("{}")) + "==." + sig, "\x00\xff.\x00",
429+ }
430+ f.advance(usecase.MinFillTime)
431+ for _, tok := range tokens {
432+ for _, s := range []ports.IssueSubmission{
433+ {Token: tok},
434+ {Token: tok, Solution: huge, FormToken: huge, Title: huge, Body: huge, Author: huge},
435+ {Token: tok, Solution: "0", FormToken: "\xff"},
436+ } {
437+ err := f.uc.Submit(context.Background(), f.repo.Name, f.addr, s)
438+ assert.ErrorIs(t, err, domain.ErrSubmissionInvalid)
439+ }
440+ }
441+ // the genuine token with garbage around it
442+ for _, s := range []ports.IssueSubmission{
443+ {Token: ch.Token, FormToken: huge, Solution: "1"},
444+ {Token: ch.Token, FormToken: ch.FormToken, Solution: huge},
445+ } {
446+ assert.ErrorIs(t, f.uc.Submit(context.Background(), f.repo.Name, f.addr, s), domain.ErrSubmissionInvalid)
447+ }
448+}
449+
450+func TestIssueSubmissionHonoursContext(t *testing.T) {
451+ t.Parallel()
452+ f := newSubFixture(t)
453+ ctx, cancel := context.WithCancel(context.Background())
454+ cancel()
455+ assert.ErrorIs(t, f.uc.Submit(ctx, f.repo.Name, f.addr, ports.IssueSubmission{}), context.Canceled)
456+ _, err := f.uc.Challenge(ctx, f.repo.Name, f.addr)
457+ assert.ErrorIs(t, err, context.Canceled)
458+}
459+
460+func TestDifficulty(t *testing.T) {
461+ t.Parallel()
462+ for _, c := range []struct{ perAddr, global, want int }{
463+ {0, 0, 18}, {1, 0, 18}, {2, 0, 19}, {3, 0, 19}, {4, 0, 20}, {6, 0, 21}, {100, 0, 21},
464+ {0, 19, 18}, {0, 20, 19}, {0, 40, 20}, {0, 60, 21}, {0, 1000, 21},
465+ {2, 20, 20}, {4, 20, 21}, {-5, -5, 18},
466+ } {
467+ assert.Equal(t, c.want, usecase.DifficultyFor(c.perAddr, c.global), "%d/%d", c.perAddr, c.global)
468+ }
469+ assert.Equal(t, 18, usecase.BaseDifficulty)
470+ assert.Equal(t, 21, usecase.MaxDifficulty)
471+}

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 43

↑↓ moveenter openesc close