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