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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

reaper_test.gno

12.86 Kb · 365 lines
  1package reaper
  2
  3import (
  4	"strings"
  5	"testing"
  6	"unicode/utf8"
  7
  8	"gno.land/p/nym-moultest123/kit/ui/v0"
  9	"gno.land/p/nym-moultest123/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}