| @@ -0,0 +1,670 @@ |
| | | 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 | + | "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 | + | |
| | | 28 | + | const testBits = 8 |
| | | 29 | + | |
| | | 30 | + | type 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 | + | |
| | | 45 | + | func (f *subFixture) advance(d time.Duration) { |
| | | 46 | + | f.mu.Lock() |
| | | 47 | + | defer f.mu.Unlock() |
| | | 48 | + | f.now = f.now.Add(d) |
| | | 49 | + | } |
| | | 50 | + | |
| | | 51 | + | func 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. |
| | | 79 | + | func (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 | + | |
| | | 89 | + | func (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 | + | |
| | | 97 | + | func 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 | + | |
| | | 110 | + | func 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. |
| | | 123 | + | func (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 | + | |
| | | 130 | + | func 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 | + | |
| | | 150 | + | func 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 | + | |
| | | 181 | + | func 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. |
| | | 209 | + | func 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 | + | |
| | | 327 | + | func 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 | + | |
| | | 345 | + | func 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 | + | |
| | | 358 | + | func 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 | + | |
| | | 381 | + | func 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 | + | |
| | | 393 | + | func 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 | + | |
| | | 420 | + | func 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 | + | |
| | | 453 | + | func 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 | + | |
| | | 463 | + | func 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. |
| | | 478 | + | type prepared struct { |
| | | 479 | + | repo domain.RepoName |
| | | 480 | + | addr string |
| | | 481 | + | sub ports.IssueSubmission |
| | | 482 | + | } |
| | | 483 | + | |
| | | 484 | + | func (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. |
| | | 499 | + | func (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 | + | |
| | | 516 | + | func 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 | + | |
| | | 557 | + | func 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 | + | |
| | | 598 | + | func 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 | + | |
| | | 619 | + | func 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). |
| | | 633 | + | func 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 | + | |
| | | 664 | + | func 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 | + | } |