reaper_test.gno
12.83 Kb · 365 lines
1package reaper
2
3import (
4 "strings"
5 "testing"
6 "unicode/utf8"
7
8 "gno.land/p/moul/kit/ui/v0"
9 "gno.land/p/moul/x/storagecost/v0"
10 "gno.land/p/nt/testutils/v0"
11 "gno.land/p/nt/uassert/v0"
12)
13
14var (
15 poster = testutils.TestAddress("poster")
16 // stranger never posts, it only deletes. The whole point of the realm is
17 // that this is the address the chain pays.
18 stranger = testutils.TestAddress("stranger")
19)
20
21// ttlFuture is the ttl used for a note that must not be reapable yet. It is a
22// named constant because clear has to outrun it.
23const ttlFuture = 1000
24
25// clear empties the board so a test starts from a known state, whatever ran
26// before it.
27//
28// gno resets the block height for each test function but keeps realm state, so
29// a test that left an unexpired note behind cannot be cleaned up by a later
30// test skipping heights: the later test's clock starts over. Every test that
31// posts a future-dated note therefore drains it before returning, and clear
32// asserts an empty board rather than trusting that.
33//
34// clear cannot reset TotalSize, which is append-addressed for the life of the
35// realm, so tests assert on Live and Reapable and never on absolute indices.
36func clear(cur realm, t *testing.T) {
37 t.Helper()
38 drain(cur)
39 uassert.Equal(t, 0, Live())
40 uassert.Equal(t, 0, Reapable())
41}
42
43// drain expires everything outstanding and reaps it.
44func drain(cur realm) {
45 testing.SkipHeights(ttlFuture + 1)
46 testing.SetRealm(testing.NewUserRealm(stranger))
47 for Reapable() > 0 {
48 Reap(cross(cur), 1000)
49 }
50 Compact(cross(cur))
51}
52
53func TestPostLocksAndReapFrees(cur realm, t *testing.T) {
54 clear(cur, t)
55
56 testing.SetRealm(testing.NewUserRealm(poster))
57 Post(cross(cur), "first", 0)
58 Post(cross(cur), "second", 0)
59 uassert.Equal(t, 2, Live())
60 uassert.Equal(t, 2, Reapable())
61
62 // A stranger who posted nothing may reap, and that is the point.
63 testing.SetRealm(testing.NewUserRealm(stranger))
64 uassert.Equal(t, 2, Reap(cross(cur), 100))
65 uassert.Equal(t, 0, Live())
66 uassert.Equal(t, 0, Reapable())
67}
68
69func TestReapSkipsUnexpiredAndHonoursLimit(cur realm, t *testing.T) {
70 clear(cur, t)
71 testing.SetRealm(testing.NewUserRealm(poster))
72
73 Post(cross(cur), "ripe one", 0)
74 Post(cross(cur), "ripe two", 0)
75 Post(cross(cur), "not yet", ttlFuture)
76 uassert.Equal(t, 3, Live())
77 uassert.Equal(t, 2, Reapable())
78
79 // The limit caps the work, so a reaper can size a transaction to its gas.
80 testing.SetRealm(testing.NewUserRealm(stranger))
81 uassert.Equal(t, 1, Reap(cross(cur), 1))
82 uassert.Equal(t, 1, Reapable())
83
84 // The unexpired note survives a reap that asks for everything.
85 uassert.Equal(t, 1, Reap(cross(cur), 100))
86 uassert.Equal(t, 0, Reapable())
87 uassert.Equal(t, 1, Live())
88
89 // Leave nothing behind: the next test's height starts over and could not
90 // expire this note.
91 drain(cur)
92}
93
94func TestReapOfNothingIsNotAnError(cur realm, t *testing.T) {
95 clear(cur, t)
96 // A bot that races another bot to the same notes must not revert, or it
97 // loses its gas to an abort instead of merely earning nothing.
98 testing.SetRealm(testing.NewUserRealm(stranger))
99 uassert.Equal(t, 0, Reap(cross(cur), 100))
100}
101
102func TestCompactFollowsReaping(cur realm, t *testing.T) {
103 clear(cur, t)
104 testing.SetRealm(testing.NewUserRealm(poster))
105 for i := 0; i < 8; i++ {
106 Post(cross(cur), "note", 0)
107 }
108
109 // Nothing is reclaimable until something has been reaped.
110 uassert.Equal(t, 0, Compactable())
111
112 testing.SetRealm(testing.NewUserRealm(stranger))
113 uassert.Equal(t, 8, Reap(cross(cur), 100))
114 uassert.True(t, Compactable() > 0)
115
116 freed := Compact(cross(cur))
117 uassert.True(t, freed > 0)
118 uassert.Equal(t, 0, Compactable())
119 // Compacting twice is not an error, it just frees nothing.
120 uassert.Equal(t, 0, Compact(cross(cur)))
121}
122
123func TestPostRejectsBadInput(cur realm, t *testing.T) {
124 clear(cur, t)
125 testing.SetRealm(testing.NewUserRealm(poster))
126
127 uassert.AbortsWithMessage(t, cur, "reaper: empty note", func() {
128 Post(cross(cur), "", 0)
129 })
130 uassert.AbortsWithMessage(t, cur, "reaper: negative ttl", func() {
131 Post(cross(cur), "fine", -1)
132 })
133 // The cap keeps one call from locking an unbounded deposit.
134 uassert.AbortsWithMessage(t, cur, "reaper: note too long, 4097 bytes against a 4096 cap", func() {
135 Post(cross(cur), strings.Repeat("x", maxBody+1), 0)
136 })
137 uassert.AbortsWithMessage(t, cur, "reaper: limit must be positive", func() {
138 Reap(cross(cur), 0)
139 })
140}
141
142func TestBountyPricesWhatIsOnTheTable(cur realm, t *testing.T) {
143 clear(cur, t)
144
145 // An empty board advertises nothing, and must not claim a profit.
146 empty := Bounty()
147 uassert.Equal(t, int64(0), empty.Bytes)
148 uassert.Equal(t, int64(0), empty.Refund)
149 uassert.False(t, empty.Worth())
150
151 testing.SetRealm(testing.NewUserRealm(poster))
152 body := strings.Repeat("x", 1024)
153 for i := 0; i < 10; i++ {
154 Post(cross(cur), body, 0)
155 }
156
157 // Ten 1 KB notes, priced through the same estimate the library documents.
158 q := Bounty()
159 uassert.Equal(t, storagecost.EstimateBytes(10*1024), q.Bytes)
160 uassert.Equal(t, storagecost.Refund(q.Bytes, storagecost.DefaultStoragePrice), q.Refund)
161 uassert.True(t, q.Worth())
162
163 // Once reaped, nothing is on the table.
164 testing.SetRealm(testing.NewUserRealm(stranger))
165 Reap(cross(cur), 100)
166 uassert.Equal(t, int64(0), Bounty().Bytes)
167}
168
169func TestRenderShowsTheBountyAndTheReapLink(cur realm, t *testing.T) {
170 clear(cur, t)
171
172 // Empty board: an invitation, and no bounty claimed.
173 out := Render("")
174 uassert.True(t, strings.Contains(out, "# Reaper"), out)
175 uassert.True(t, strings.Contains(out, "Nothing has expired"), out)
176 uassert.True(t, strings.Contains(out, "Post the first note"), out)
177
178 testing.SetRealm(testing.NewUserRealm(poster))
179 Post(cross(cur), strings.Repeat("y", 1024), 0)
180
181 out = Render("")
182 uassert.True(t, strings.Contains(out, "1 expired notes"), out)
183 uassert.True(t, strings.Contains(out, "refunds roughly **0.1894 GNOT**"), out)
184 // The reap link must be a help link for the right function with the right
185 // argument, or the button on the page does nothing useful. txlink builds
186 // it relative to the current realm, so there is no path to get wrong.
187 uassert.True(t, strings.Contains(out, "$help&func=Reap&limit=100"), out)
188 // The author is named, in the house address format, which is what makes
189 // "the poster paid" visible without 40 opaque characters per row.
190 uassert.True(t, strings.Contains(out, ui.Addr(poster)), out)
191 uassert.True(t, strings.Contains(out, "**reapable**"), out)
192
193 // A long note is summarized rather than dumped into the table.
194 uassert.True(t, strings.Contains(out, ui.Ellipsis), out)
195 uassert.False(t, strings.Contains(out, strings.Repeat("y", 200)), "body should be truncated")
196}
197
198func TestRenderNeverEmitsTwoBlankLines(cur realm, t *testing.T) {
199 // gno collapses two consecutive blank lines, so output containing them can
200 // never be pinned by an Example. This test is what lets ExampleRender stay
201 // meaningful.
202 clear(cur, t)
203 testing.SetRealm(testing.NewUserRealm(poster))
204 Post(cross(cur), "one", 0)
205 Post(cross(cur), "two", ttlFuture)
206 testing.SetRealm(testing.NewUserRealm(stranger))
207 Reap(cross(cur), 1)
208
209 for _, path := range []string{"", "anything"} {
210 out := Render(path)
211 uassert.False(t, strings.Contains(out, "\n\n\n"), "blank-line run in Render("+path+")")
212 }
213
214 drain(cur)
215}
216
217func TestReapWalksNewestFirstSoCompactionHasWork(cur realm, t *testing.T) {
218 // The ordering is economic, not cosmetic: in the backing list the oldest
219 // indices are the ancestors of the newest, so a partial reap that took the
220 // oldest first would leave nothing compactable and strand the tree
221 // structure, which costs more than the notes do.
222 clear(cur, t)
223 testing.SetRealm(testing.NewUserRealm(poster))
224 for i := 0; i < 16; i++ {
225 Post(cross(cur), "note", 0)
226 }
227 first := notes.TotalSize() - 16
228 last := notes.TotalSize() - 1
229
230 testing.SetRealm(testing.NewUserRealm(stranger))
231 uassert.Equal(t, 4, Reap(cross(cur), 4))
232
233 // The four highest indices went, and the four lowest survived.
234 uassert.Equal(t, nil, notes.Get(last))
235 uassert.True(t, notes.Get(first) != nil)
236
237 // Which is the whole point: there is something to compact after one batch.
238 uassert.True(t, Compactable() > 0)
239
240 drain(cur)
241}
242
243// TestSummarizeNeutralizesAnAttackerAuthoredBody is the regression test for a
244// hole this realm shipped with: Render emitted a note body into the board
245// after nothing but a ReplaceAll of "\n" and "|", so anyone who paid to post
246// could put arbitrary markdown on a page every reader of the realm loads.
247//
248// The body is the one string here an attacker controls and the board is an
249// INLINE slot, so sanitize.InlineText owns it. Each case asserts the dangerous
250// SEQUENCE is dead and the readable text survived, rather than pinning the
251// exact escaped bytes, which are the sanitizer's business and not this realm's.
252func TestSummarizeNeutralizesAnAttackerAuthoredBody(t *testing.T) {
253 cases := []struct {
254 name string
255 body string
256 gone []string // sequences that must not survive into the page
257 kept string // the readable text, which must
258 }{
259 {
260 // The payload that matters: a phishing link rendered as prose,
261 // attributed by the page to the realm rather than to the poster.
262 name: "inline link",
263 body: "[Claim your 100 GNOT](https://evil.example/drain)",
264 gone: []string{"](", "](https"},
265 kept: "Claim your 100 GNOT",
266 },
267 {
268 // An image is a beacon: it fires on load and hands the attacker
269 // the IP of everyone who opened the board.
270 name: "image beacon",
271 body: "",
272 gone: []string{" stopped exactly this and nothing
285 // else. Keep it dead: one newline ends the bullet item, and every
286 // byte after it is top-level markdown.
287 name: "break out of the list item",
288 body: "ok\n# Reaper is deprecated, use /r/evil/reaper",
289 gone: []string{"\n"},
290 kept: "Reaper is deprecated",
291 },
292 {
293 // Folding only "\n" left the other three line terminators open.
294 name: "exotic line terminators",
295 body: "ok\r\n- fake row\u2028- another\u2029- third",
296 gone: []string{"\r", "\n", "\u2028", "\u2029"},
297 kept: "fake row",
298 },
299 {
300 // Invisible reordering: the bytes say one thing, the page shows
301 // another, and a reader diffing the two sees nothing.
302 name: "bidi and zero-width",
303 body: "safe\u202enote\u200b\u202c",
304 gone: []string{"\u202e", "\u200b", "\u202c"},
305 kept: "safe",
306 },
307 }
308
309 for _, tc := range cases {
310 t.Run(tc.name, func(t *testing.T) {
311 out := summarize(tc.body)
312 for _, bad := range tc.gone {
313 uassert.False(t, strings.Contains(out, bad),
314 tc.name+": a dangerous sequence survived into "+out)
315 }
316 uassert.True(t, strings.Contains(out, tc.kept),
317 tc.name+": escaping ate the readable text: "+out)
318 uassert.True(t, utf8.ValidString(out), tc.name+": invalid UTF-8: "+out)
319 })
320 }
321}
322
323// TestSummarizeEscapesAnUnbalancedBracket covers the cross-row case: a "[" in
324// one note and a "]" in another used to let two cheap notes bracket every row
325// between them into one link.
326func TestSummarizeEscapesAnUnbalancedBracket(t *testing.T) {
327 uassert.Equal(t, "\\[", summarize("["))
328 uassert.Equal(t, "\\]", summarize("]"))
329 uassert.Equal(t, "\\[", ui.Excerpt("[", 48))
330}
331
332// TestSummarizeCutsOnARuneBoundary pins the other half of the old bug: the
333// truncation sliced at a byte offset, so a body of multi-byte characters put
334// half a character on the page. One emoji in a note was enough.
335func TestSummarizeCutsOnARuneBoundary(t *testing.T) {
336 // The leading "x" puts byte 48, the old cut point, in the middle of a
337 // 4-byte rune rather than on a boundary.
338 out := summarize("x" + strings.Repeat("\U0001F33E", 60))
339 uassert.True(t, utf8.ValidString(out), "cut mid-rune: "+out)
340 uassert.True(t, strings.HasSuffix(out, ui.Ellipsis), "long body should be elided: "+out)
341
342 // A short body comes back whole, with no elision and nothing to escape.
343 short := summarize("plain note")
344 uassert.Equal(t, "plain note", short)
345}
346
347// TestRenderEscapesTheBoard is the end-to-end half: the sanitizer is only
348// worth anything if Render is what calls it, and the board is the one place a
349// stranger's bytes reach the page.
350func TestRenderEscapesTheBoard(cur realm, t *testing.T) {
351 clear(cur, t)
352 testing.SetRealm(testing.NewUserRealm(poster))
353 Post(cross(cur), "[Claim 100 GNOT](https://evil.example)\n# Official", 0)
354
355 out := Render("")
356 uassert.False(t, strings.Contains(out, "](https://evil.example)"), out)
357 uassert.False(t, strings.Contains(out, "\n# Official"), out)
358 // The note stays legible, just inert: the text survives, the chrome does not.
359 uassert.True(t, strings.Contains(out, "Claim 100 GNOT"), out)
360 uassert.True(t, utf8.ValidString(out), "Render emitted invalid UTF-8")
361
362 testing.SetRealm(testing.NewUserRealm(stranger))
363 Reap(cross(cur), 100)
364 Compact(cross(cur))
365}