garden_test.gno
20.38 Kb · 449 lines
1package garden
2
3import (
4 "strconv"
5 "strings"
6 "testing"
7
8 "gno.land/p/nt/grc721/v0"
9 "gno.land/p/nt/testutils/v0"
10 "gno.land/p/nt/uassert/v0"
11
12 "chain"
13)
14
15var (
16 alice = testutils.TestAddress("alice")
17 bob = testutils.TestAddress("bob")
18 carl = testutils.TestAddress("carl")
19 dave = testutils.TestAddress("dave")
20)
21
22// adoptAs sets the test harness's caller identity to addr (the pattern
23// p/nt/grc721/v0's own tellers_test.gno uses: testing.SetRealm +
24// testing.NewUserRealm) and then crosses into Adopt as that address, the
25// same way a real Adena-signed MsgCall would arrive as Adopt's caller.
26func adoptAs(cur realm, addr address, name, hat, coat string) {
27 testing.SetRealm(testing.NewUserRealm(addr))
28 Adopt(cross(cur), name, hat, coat)
29}
30
31func TestAdoptMintsSoulboundGnome(cur realm, t *testing.T) {
32 adoptAs(cur, alice, "Bramble", "red", "blue")
33
34 tid, name, hat, coat, level, gnomeType, adoptedAt, found := GnomeByAddress(alice)
35 uassert.True(t, found, "alice should have a gnome after Adopt")
36 uassert.True(t, tid != "", "token id should not be empty")
37 uassert.Equal(t, "Bramble", name)
38 uassert.Equal(t, "red", hat)
39 uassert.Equal(t, "blue", coat)
40 uassert.Equal(t, int64(1), level)
41 uassert.Equal(t, "", gnomeType, "type is unset until a later phase, matching live.js's adopt() stub")
42 uassert.True(t, adoptedAt > 0, "adoptedAt should be a real unix timestamp")
43
44 owner, err := token.OwnerOf(grc721.TokenID(tid))
45 uassert.NoError(t, err)
46 uassert.Equal(t, alice, owner, "grc721 ledger and this realm's own index must agree on the owner")
47
48 page := Render(string(alice))
49 uassert.True(t, strings.Contains(page, "Bramble"), "Render should show the gnome's name")
50 uassert.True(t, strings.Contains(page, "Level"), "Render should show the gnome's level")
51 uassert.True(t, strings.Contains(page, "red hat"), "Render should show the gnome's look")
52}
53
54func TestRenderUnknownAddressDoesNotPanic(t *testing.T) {
55 // Its own address, not bob: this used to assert bob had no gnome, and that only held
56 // because Go runs top-level test functions in declaration order and this realm's
57 // package-level state (ownerIndex/gnomes) persists across test functions within one
58 // `gno test` run -- TestAdoptRejectsSecondAdoptSameAddress below adopts bob later in the
59 // same file. That made this test true by file-ordering accident, not by anything it
60 // actually asserts; a dedicated, never-adopted address can't regress that way if the
61 // tests above are ever reordered or a new one is inserted earlier in the file.
62 addr := testutils.TestAddress("render-unknown")
63 page := Render(string(addr))
64 uassert.True(t, strings.Contains(page, "No gnome adopted yet"), "an address with no gnome should get an honest message, not a panic")
65}
66
67func TestAdoptRejectsSecondAdoptSameAddress(cur realm, t *testing.T) {
68 adoptAs(cur, bob, "First", "red", "blue")
69
70 uassert.AbortsWithMessage(t, cur, "garden: this address has already adopted a gnome", func() {
71 adoptAs(cur, bob, "Second", "green", "teal")
72 })
73
74 // The rejected second attempt must not have replaced the first gnome.
75 _, name, _, _, _, _, _, found := GnomeByAddress(bob)
76 uassert.True(t, found)
77 uassert.Equal(t, "First", name)
78}
79
80func TestAdoptRejectsEveryInvalidHat(cur realm, t *testing.T) {
81 invalidHats := []string{"pink", "RED", "", "blue" /* a valid coat, not a valid hat */}
82 for i, hat := range invalidHats {
83 hat := hat
84 addr := testutils.TestAddress("hat-" + strconv.Itoa(i))
85 t.Run(hat, func(t *testing.T) {
86 uassert.AbortsWithMessage(t, cur, "garden: invalid hat colour", func() {
87 adoptAs(cur, addr, "Pip", hat, "blue")
88 })
89 _, _, _, _, _, _, _, found := GnomeByAddress(addr)
90 uassert.False(t, found, "a rejected adopt must not mint anything")
91 })
92 }
93}
94
95func TestAdoptRejectsEveryInvalidCoat(cur realm, t *testing.T) {
96 invalidCoats := []string{"black", "BLUE", "", "red" /* a valid hat, not a valid coat */}
97 for i, coat := range invalidCoats {
98 coat := coat
99 addr := testutils.TestAddress("coat-" + strconv.Itoa(i))
100 t.Run(coat, func(t *testing.T) {
101 uassert.AbortsWithMessage(t, cur, "garden: invalid coat colour", func() {
102 adoptAs(cur, addr, "Pip", "red", coat)
103 })
104 _, _, _, _, _, _, _, found := GnomeByAddress(addr)
105 uassert.False(t, found, "a rejected adopt must not mint anything")
106 })
107 }
108}
109
110func TestAdoptRejectsEmptyAndOverLongName(cur realm, t *testing.T) {
111 overLong := strings.Repeat("a", maxNameLen+1)
112 wantMsg := "garden: name must be 1-" + strconv.Itoa(maxNameLen) + " characters"
113
114 tests := []struct {
115 name string
116 id string
117 }{
118 {"", "empty-name"},
119 {overLong, "overlong-name"},
120 }
121 for _, tc := range tests {
122 tc := tc
123 addr := testutils.TestAddress(tc.id)
124 t.Run(tc.id, func(t *testing.T) {
125 uassert.AbortsWithMessage(t, cur, wantMsg, func() {
126 adoptAs(cur, addr, tc.name, "red", "blue")
127 })
128 _, _, _, _, _, _, _, found := GnomeByAddress(addr)
129 uassert.False(t, found, "a rejected adopt must not mint anything")
130 })
131 }
132}
133
134func TestAdoptAcceptsNameAtMaxLength(cur realm, t *testing.T) {
135 // Boundary check alongside the over-long rejection above: exactly
136 // maxNameLen must still succeed.
137 maxName := strings.Repeat("z", maxNameLen)
138 addr := testutils.TestAddress("maxname")
139 adoptAs(cur, addr, maxName, "navy", "orange")
140
141 _, name, _, _, _, _, _, found := GnomeByAddress(addr)
142 uassert.True(t, found)
143 uassert.Equal(t, maxName, name)
144}
145
146// TestTransferAlwaysFails is rule 6's guarantee ("every transfer fails"),
147// proven rather than assumed: a real transfer is attempted through
148// grc721's own ledger -- the same PrivateLedger.transfer path any
149// TransferFrom wrapper would ultimately call -- and it must panic with the
150// soulbound extension's message. This realm deliberately exposes no public
151// Transfer/TransferFrom wrapper at all (see garden.gno's package doc), so
152// the raw ledger call is the actual mechanism, not a stand-in for one.
153//
154// uassert.PanicsWithMessage, not AbortsWithMessage: ledger.TransferFrom is a
155// plain method call, not a crossing call (it is not reached via a
156// `cross(...)`-invoked function), so this is a same-realm local panic per
157// that function's own doc ("Use PanicsWithMessage for asserting local
158// panics within the same realm").
159//
160// What this test does NOT assert, on purpose: that grc721's own ledger
161// (token.OwnerOf) is untouched after the refused attempt. Checked
162// empirically against this harness, it is not -- PrivateLedger.transfer()
163// (p/nt/grc721/v0) writes owners/balances *before* running extension hooks,
164// and recovering a local panic with PanicsWithMessage does not undo writes
165// made earlier in the same call. On a live chain that half-written state is
166// never observed by anyone, because the whole enclosing transaction aborts
167// atomically at the node's transaction-processing layer -- but `gno test`
168// does not model that layer at all (realm state is documented to persist
169// *between* whole test functions precisely because there is no such
170// rollback). Asserting token.OwnerOf here would therefore be testing a
171// property of `gno test` itself, not of this realm's code. What this realm
172// actually promises its callers -- GnomeByAddress and Render, its own
173// authoritative read path -- is asserted below, and that one holds:
174// ownerIndex/gnomes are written only by Adopt, never by grc721's internal
175// transfer(), so a refused attempt cannot change what they report.
176func TestTransferAlwaysFails(cur realm, t *testing.T) {
177 wantMsg := "garden: gnomes are soulbound; transfer is permanently disabled"
178
179 adoptAs(cur, carl, "Fern", "navy", "brown")
180 tid, _, _, _, _, _, _, found := GnomeByAddress(carl)
181 uassert.True(t, found)
182
183 uassert.PanicsWithMessage(t, cur, wantMsg, func() {
184 if err := ledger.TransferFrom(carl, carl, dave, grc721.TokenID(tid)); err != nil {
185 panic(err)
186 }
187 })
188
189 _, name, _, _, _, _, _, found := GnomeByAddress(carl)
190 uassert.True(t, found)
191 uassert.Equal(t, "Fern", name, "this realm's own read must still show the refused transfer's owner")
192
193 _, _, _, _, _, _, _, found = GnomeByAddress(dave)
194 uassert.False(t, found, "dave adopted nothing and must not appear to have received a gnome through the refused transfer")
195}
196
197// TestAdoptRejectsNamesWithMarkdownZeroWidthAndBidiCharacters is the audit's
198// finding 2 (Render sanitisation), proven at the input boundary:
199// validNameRune is an allowlist, so every one of these -- markdown
200// metacharacters that could break out of Render's "# name" heading or
201// inject structure into a future shared page, a zero-width space that would
202// make two visually-identical names byte-distinct, and a bidi override that
203// could make a rendered name display as something other than what it
204// stores -- must be refused at Adopt time, long before Render ever reads
205// the name back.
206//
207// wantMsg asserts the charset message specifically (garden.gno's Adopt now
208// gives length and charset failures two different messages -- see the
209// second audit round's finding: every name below used to be 9+ runes and
210// this test asserted the *length* message, so it would still have passed
211// even if validNameRune were deleted outright, since none of these are
212// anywhere near maxNameLen). The short two-rune cases at the end of the list
213// close that gap: "a`" and "aㅤ" are both well inside the length budget,
214// so the only way either can fail is the charset rule actually firing.
215func TestAdoptRejectsNamesWithMarkdownZeroWidthAndBidiCharacters(cur realm, t *testing.T) {
216 bad := []string{
217 "back`tick",
218 "star*wars",
219 "under_score",
220 "brack[ets]",
221 "paren(thesis)",
222 "pipe|name",
223 "hash#tag",
224 "angle<brackets>",
225 "escape\\name",
226 "zerowidth", // U+200B ZERO WIDTH SPACE
227 "bidioverride", // U+202E RIGHT-TO-LEFT OVERRIDE
228 "isolatebidi", // U+2066 LEFT-TO-RIGHT ISOLATE
229 "joinercharity", // U+200D ZERO WIDTH JOINER
230 "a`", // 2 runes: short enough that only the charset rule can reject it
231 "aㅤ", // 2 runes: a letter plus a Hangul filler masquerading as one
232 }
233 wantMsg := "garden: name contains a character that is not an allowed letter, digit, space, hyphen or apostrophe"
234 for i, name := range bad {
235 name := name
236 addr := testutils.TestAddress("badname-" + strconv.Itoa(i))
237 t.Run(strconv.Itoa(i), func(t *testing.T) {
238 uassert.AbortsWithMessage(t, cur, wantMsg, func() {
239 adoptAs(cur, addr, name, "red", "blue")
240 })
241 _, _, _, _, _, _, _, found := GnomeByAddress(addr)
242 uassert.False(t, found, "a name with a markdown/zero-width/bidi character must not mint anything")
243 })
244 }
245}
246
247// TestAdoptRejectsInvisibleLetterOnlyAndSpaceOnlyNames is the second audit
248// round's finding 1, proven directly: unicode.IsLetter admits several
249// HANGUL FILLER code points (category Lo) that render as nothing, so a name
250// made of only one of those was accepted by the old allowlist despite
251// validNameRune's own comment claiming nothing outside letters-and-digits
252// could get in "full stop". Separately, validName never required any
253// visible content at all, so a name of a single space was also accepted --
254// and with no rename, no burn and no admin in this realm, permanently.
255// Both must now be refused, and refused with their own distinct messages
256// (charset vs. visible-content), not the length message.
257//
258// The letter-filler half is table-driven straight from invisibleLetterRunes
259// itself, not a hand-copied list of runes (third audit round, finding Y3):
260// before this change, every test here (Go and JS) only ever exercised
261// U+3164, so deleting U+115F, U+1160 or U+FFA0 from that map -- or, more
262// dangerously, mistyping one of them (e.g. 'ᅟ' as 'ᅡ', which is a
263// *visible* Korean vowel, not a filler) -- would still pass every test in
264// this repo while quietly breaking the guarantee validNameRune's own comment
265// claims. Ranging over the map directly means a change to the map is a
266// change to what this test checks, by construction.
267func TestAdoptRejectsInvisibleLetterOnlyAndSpaceOnlyNames(cur realm, t *testing.T) {
268 wantCharsetMsg := "garden: name contains a character that is not an allowed letter, digit, space, hyphen or apostrophe"
269 i := 0
270 for r := range invisibleLetterRunes {
271 r := r
272 idx := i
273 i++
274 addr := testutils.TestAddress("invisible-letter-" + strconv.Itoa(idx))
275 t.Run("U+"+strconv.FormatInt(int64(r), 16), func(t *testing.T) {
276 uassert.AbortsWithMessage(t, cur, wantCharsetMsg, func() {
277 adoptAs(cur, addr, string(r), "red", "blue")
278 })
279 _, _, _, _, _, _, _, found := GnomeByAddress(addr)
280 uassert.False(t, found, "an invisible-letter-only name must not mint anything")
281 })
282 }
283
284 spaceOnly := []struct {
285 label string
286 name string
287 }{
288 {"space-only-single", " "},
289 {"space-only-multiple", " "},
290 }
291 wantSpaceMsg := "garden: name must contain at least one visible character"
292 for i, tc := range spaceOnly {
293 tc := tc
294 addr := testutils.TestAddress("invisible-space-" + strconv.Itoa(i))
295 t.Run(tc.label, func(t *testing.T) {
296 uassert.AbortsWithMessage(t, cur, wantSpaceMsg, func() {
297 adoptAs(cur, addr, tc.name, "red", "blue")
298 })
299 _, _, _, _, _, _, _, found := GnomeByAddress(addr)
300 uassert.False(t, found, "a name with no visible content must not mint anything")
301 })
302 }
303}
304
305// TestAdoptAcceptsCrossScriptHomoglyphs documents and locks in a deliberate,
306// accepted residual (see validNameRune's own comment): this allowlist closes
307// invisible/structural characters, not cross-script look-alikes. A Cyrillic
308// "А" (U+0410) is an ordinary letter by Unicode's own classification, and
309// this realm has no reason to reject it -- gnome names are not identifiers,
310// nothing requires one to be unique or uses one as a lookup key, so two
311// visually-confusable names are a cosmetic curiosity, not a vulnerability.
312// This test exists so a future tightening of validNameRune does not silently
313// start rejecting ordinary single-code-point non-Latin letters while
314// believing it is only closing an invisible-character hole.
315//
316// What this test does NOT claim, corrected in the third audit round: that
317// the current rule accepts "ordinary non-Latin letters" in general. It does
318// not. validNameRune admits individual letter/digit code points and nothing
319// else, and a large class of ordinary non-Latin spellings are built from a
320// base letter plus one or more combining marks (Unicode category Mn) --
321// NFD-normalised Latin, the usual spellings of Devanagari, Thai, Hebrew with
322// niqqud and Arabic with harakat among them. Every combining mark is
323// rejected outright today, so those spellings are not nameable at all, not
324// merely confusable with something else. That is a real, deliberate
325// exclusion, not a bug this test is guarding -- see NOTES.md for the
326// trade-off and why it is a product decision, not an engineering one. This
327// test only ever exercises a single-code-point homoglyph (Cyrillic "А" has
328// no combining marks involved), so it says nothing about, and must not be
329// read as evidence for, the combining-mark case.
330func TestAdoptAcceptsCrossScriptHomoglyphs(cur realm, t *testing.T) {
331 addr := testutils.TestAddress("homoglyph")
332 name := "Аlice" // Cyrillic А + "lice", not the Latin "Alice"
333 adoptAs(cur, addr, name, "red", "blue")
334
335 _, gotName, _, _, _, _, _, found := GnomeByAddress(addr)
336 uassert.True(t, found)
337 uassert.Equal(t, name, gotName)
338}
339
340// TestAdoptAcceptsOrdinaryPunctuationInName checks validNameRune's allowlist
341// is not so tight it breaks ordinary names: a space, a hyphen and an
342// apostrophe are all real characters pack/03-screens' name cards expect to
343// be able to show (e.g. a hyphenated or possessive gnome name).
344func TestAdoptAcceptsOrdinaryPunctuationInName(cur realm, t *testing.T) {
345 addr := testutils.TestAddress("ordinary-punctuation")
346 adoptAs(cur, addr, "Mary-Jane O'Hare", "navy", "teal")
347
348 _, name, _, _, _, _, _, found := GnomeByAddress(addr)
349 uassert.True(t, found)
350 uassert.Equal(t, "Mary-Jane O'Hare", name)
351}
352
353// TestAdoptAcceptsMultiByteNameWithinRuneBudget and
354// TestAdoptRejectsMultiByteNameOverRuneBudget are the "plain bug" fix:
355// validName used to count len(name), which is bytes, while maxNameLen (and
356// the spec) mean characters. "é" is 2 UTF-8 bytes per rune, so 24 of them is
357// 48 bytes -- over budget under the old byte-counting bug despite being
358// exactly 24 characters, which is what the rule was always supposed to
359// allow.
360func TestAdoptAcceptsMultiByteNameWithinRuneBudget(cur realm, t *testing.T) {
361 name := strings.Repeat("é", maxNameLen)
362 uassert.True(t, len(name) > maxNameLen, "sanity: this name must be over budget by byte length")
363 uassert.Equal(t, maxNameLen, len([]rune(name)), "sanity: this name must be exactly on budget by rune count")
364
365 addr := testutils.TestAddress("multibyte-name")
366 adoptAs(cur, addr, name, "red", "blue")
367
368 _, gotName, _, _, _, _, _, found := GnomeByAddress(addr)
369 uassert.True(t, found)
370 uassert.Equal(t, name, gotName)
371}
372
373func TestAdoptRejectsMultiByteNameOverRuneBudget(cur realm, t *testing.T) {
374 name := strings.Repeat("é", maxNameLen+1)
375 addr := testutils.TestAddress("multibyte-overlong")
376 wantMsg := "garden: name must be 1-" + strconv.Itoa(maxNameLen) + " characters"
377
378 uassert.AbortsWithMessage(t, cur, wantMsg, func() {
379 adoptAs(cur, addr, name, "red", "blue")
380 })
381 _, _, _, _, _, _, _, found := GnomeByAddress(addr)
382 uassert.False(t, found, "a name one rune over budget must not mint anything, regardless of byte length")
383}
384
385// TestRenderRejectsInvalidPathWithoutEchoingIt is the audit's finding 3:
386// address(path) is an unchecked conversion, and the old code echoed an
387// unvalidated path straight into the page inside backticks on the
388// not-found branch. A backtick (or any other markdown metacharacter) in an
389// invalid path must never reach the rendered output.
390func TestRenderRejectsInvalidPathWithoutEchoingIt(t *testing.T) {
391 junk := "not-a-valid-address`*[](){}<script>"
392 page := Render(junk)
393
394 uassert.True(t, strings.Contains(page, "invalid address"), "an invalid path should get a clear, generic refusal")
395 uassert.False(t, strings.Contains(page, junk), "the raw junk path must never be echoed back into the page")
396 uassert.False(t, strings.Contains(page, "`"), "no backtick from caller input should ever reach the page")
397}
398
399// TestAlreadyAdoptedChecksOwnerIndexNotJustBalance is the audit's finding 4:
400// ownerIndex (this realm's own record, written once by Adopt and never
401// deleted) must be consulted, not just grc721's balance. ownerIndex is
402// written only by Adopt in real operation; setting it directly here is the
403// only way, short of a second ledger implementation, to exercise "ownerIndex
404// says adopted, the ledger balance alone would not" and prove the fix
405// actually consults both.
406//
407// This permanently poisons addr's ownerIndex entry (third audit round,
408// test-hygiene item): the address below is set to "already adopted" by
409// writing straight into ownerIndex, and this realm has no admin, burn or
410// rename to ever undo that within a single `gno test` run -- realm state
411// persists across test functions, the same property TestRenderUnknownAddressDoesNotPanic's
412// own fix above had to account for. Nothing else in this file reuses
413// testutils.TestAddress("owner-index-only") today; it must stay that way --
414// any future test that adopts as this address would abort on "already
415// adopted" for a reason that has nothing to do with whatever it is testing.
416func TestAlreadyAdoptedChecksOwnerIndexNotJustBalance(cur realm, t *testing.T) {
417 addr := testutils.TestAddress("owner-index-only")
418 ownerIndex.Set(addr.String(), "nonexistent-token-id")
419
420 balance, err := token.BalanceOf(addr)
421 uassert.NoError(t, err)
422 uassert.Equal(t, int64(0), balance, "grc721's own ledger must not have been touched by poking ownerIndex directly")
423
424 uassert.True(t, alreadyAdopted(addr), "ownerIndex alone must be enough to report already-adopted")
425
426 uassert.AbortsWithMessage(t, cur, "garden: this address has already adopted a gnome", func() {
427 adoptAs(cur, addr, "ShouldNotMint", "red", "blue")
428 })
429}
430
431// TestAdoptRejectsAttachedCoins is the audit's finding 5: Adopt is a public
432// crossing function, and this realm has no banker and deliberately no
433// withdrawal path, so any coins a caller attaches would be stranded
434// forever. Adopt must refuse the call outright rather than accept and
435// strand them.
436func TestAdoptRejectsAttachedCoins(cur realm, t *testing.T) {
437 addr := testutils.TestAddress("paid")
438
439 testing.SetRealm(testing.NewUserRealm(addr))
440 testing.SetOriginSend(chain.Coins{{Denom: "ugnot", Amount: 1}})
441 defer testing.SetOriginSend(chain.Coins{})
442
443 uassert.AbortsWithMessage(t, cur, "garden: adopt accepts no coins; this realm has no banker and cannot return them", func() {
444 Adopt(cross(cur), "Payer", "red", "blue")
445 })
446
447 _, _, _, _, _, _, _, found := GnomeByAddress(addr)
448 uassert.False(t, found, "a rejected adopt must not mint anything, coins or not")
449}