Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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: "![x](https://evil.example/pixel.png)",
272			gone: []string{"![", "]("},
273			kept: "pixel",
274		},
275		{
276			// gnoweb renders a set of <gno-...> components, and raw HTML
277			// besides; a body that can open one can lay out the page.
278			name: "gnoweb component and raw html",
279			body: "<gno-columns><h5>Official notice</h5>",
280			gone: []string{"<gno-", "<h5>", "</h5>"},
281			kept: "Official notice",
282		},
283		{
284			// The old ReplaceAll("\n", " ") 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}