1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package clientlimit_test
4
5import (
6 "fmt"
7 "sync"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite/internal/clientlimit"
16)
17
18func newSubs() (*clientlimit.SubmissionLimiter, *fakeClock) {
19 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
20 return clientlimit.NewSubmissionLimiter(clk.now), clk
21}
22
23func TestSubmissionHourWindow(t *testing.T) {
24 t.Parallel()
25 l, clk := newSubs()
26 for i := range clientlimit.PerHour {
27 ok, _, _ := l.Reserve(bg, "1.2.3.4")
28 require.True(t, ok, "submission %d", i)
29 clk.t = clk.t.Add(time.Minute)
30 }
31 ok, wait, _ := l.Reserve(bg, "1.2.3.4")
32 assert.False(t, ok)
33 assert.Equal(t, time.Hour-3*time.Minute, wait, "until the first one leaves the hour")
34
35 other, _, _ := l.Reserve(bg, "5.6.7.8")
36 assert.True(t, other, "another client is not affected")
37
38 clk.t = clk.t.Add(wait)
39 ok, _, _ = l.Reserve(bg, "1.2.3.4")
40 assert.True(t, ok, "the window slides")
41}
42
43func TestSubmissionDayWindow(t *testing.T) {
44 t.Parallel()
45 l, clk := newSubs()
46 start := clk.t
47 // 3 per hour, spaced so the hourly limit never bites: one every 2 hours.
48 for range clientlimit.PerDay {
49 ok, _, _ := l.Reserve(bg, "1.2.3.4")
50 require.True(t, ok)
51 clk.t = clk.t.Add(2 * time.Hour)
52 }
53 // 20h after start: hourly window empty, day window full.
54 ok, wait, _ := l.Reserve(bg, "1.2.3.4")
55 assert.False(t, ok)
56 assert.Equal(t, start.Add(clientlimit.DayWindow).Sub(clk.t), wait)
57
58 clk.t = start.Add(clientlimit.DayWindow)
59 ok, _, _ = l.Reserve(bg, "1.2.3.4")
60 assert.True(t, ok)
61}
62
63func TestSubmissionIPv6GroupsBy64(t *testing.T) {
64 t.Parallel()
65 l, _ := newSubs()
66 for i := range clientlimit.PerHour {
67 l.Reserve(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1)) // same /64
68 }
69 ok, _, _ := l.Reserve(bg, "2001:db8:1:2:ffff::1")
70 assert.False(t, ok, "the whole /64 shares the budget")
71 ok, _, _ = l.Reserve(bg, "2001:db8:1:3::1")
72 assert.True(t, ok, "another /64 does not")
73 ok, _, _ = l.Reserve(bg, "::ffff:1.2.3.4")
74 assert.True(t, ok)
75 perAddr, _ := l.Recent(bg, "1.2.3.4")
76 assert.Equal(t, 1, perAddr, "a mapped address counts as its IPv4")
77}
78
79func TestSubmissionRecent(t *testing.T) {
80 t.Parallel()
81 l, clk := newSubs()
82 l.Reserve(bg, "1.1.1.1")
83 l.Reserve(bg, "1.1.1.1")
84 l.Reserve(bg, "2.2.2.2")
85 perAddr, global := l.Recent(bg, "1.1.1.1")
86 assert.Equal(t, 2, perAddr)
87 assert.Equal(t, 3, global)
88
89 clk.t = clk.t.Add(time.Hour)
90 perAddr, global = l.Recent(bg, "1.1.1.1")
91 assert.Zero(t, perAddr, "Recent looks at the last hour")
92 assert.Zero(t, global)
93}
94
95func TestSubmissionEvictsOldest(t *testing.T) {
96 t.Parallel()
97 l, clk := newSubs()
98 for i := range clientlimit.MaxTracked + 10 {
99 clk.t = clk.t.Add(time.Millisecond)
100 l.Reserve(bg, fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
101 }
102 assert.LessOrEqual(t, l.Tracked(), clientlimit.MaxTracked, "bounded")
103 ok, _, _ := l.Reserve(bg, "10.0.0.0")
104 assert.True(t, ok, "the oldest were evicted")
105}
106
107func TestSubmissionReserveIsAtomic(t *testing.T) {
108 t.Parallel()
109 l, _ := newSubs()
110 const n = 50
111 var granted atomic.Int32
112 var wg sync.WaitGroup
113 start := make(chan struct{})
114 for range n {
115 wg.Add(1)
116 go func() {
117 defer wg.Done()
118 <-start
119 if ok, _, _ := l.Reserve(bg, "1.2.3.4"); ok {
120 granted.Add(1)
121 }
122 }()
123 }
124 close(start)
125 wg.Wait()
126 assert.Equal(t, int32(clientlimit.PerHour), granted.Load())
127}
128
129func TestSubmissionReleaseRefunds(t *testing.T) {
130 t.Parallel()
131 l, _ := newSubs()
132 var releases []func()
133 for range clientlimit.PerHour {
134 ok, _, rel := l.Reserve(bg, "1.2.3.4")
135 require.True(t, ok)
136 releases = append(releases, rel)
137 }
138 ok, _, rel := l.Reserve(bg, "1.2.3.4")
139 require.False(t, ok)
140 rel() // no-op on a refusal
141 releases[0]()
142 releases[0]() // idempotent
143 perAddr, global := l.Recent(bg, "1.2.3.4")
144 assert.Equal(t, clientlimit.PerHour-1, perAddr)
145 assert.Equal(t, clientlimit.PerHour-1, global)
146 ok, _, _ = l.Reserve(bg, "1.2.3.4")
147 assert.True(t, ok, "the refunded slot is usable again")
148}