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